Compare commits
168 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c5a2324aaf | ||
|
|
128474b4aa | ||
|
|
7fca8f50f8 | ||
|
|
841bb417d1 | ||
|
|
59f1d67a4e | ||
|
|
1600ba6645 | ||
|
|
127c996e86 | ||
|
|
4bd39c07e9 | ||
|
|
f5b68dc590 | ||
|
|
875bd3b6ec | ||
|
|
137b74750c | ||
|
|
ded00dc422 | ||
|
|
5df6c2431b | ||
|
|
9066988098 | ||
|
|
1dc4977ff9 | ||
|
|
96e234012d | ||
|
|
6c3f3b6576 | ||
|
|
0df6d7a441 | ||
|
|
4ea7041ba9 | ||
|
|
7dae68de78 | ||
|
|
60495ed9b0 | ||
|
|
0aaef661b1 | ||
|
|
abbe7858a2 | ||
|
|
767af21e02 | ||
|
|
27c498e0e7 | ||
|
|
0fb9646c4d | ||
|
|
9728527900 | ||
|
|
675b083db3 | ||
|
|
0b74cdaaf8 | ||
|
|
f31ef598bc | ||
|
|
656779531c | ||
|
|
eec177d3ac | ||
|
|
54ed7c3200 | ||
|
|
3cd74a824a | ||
|
|
177b008328 | ||
|
|
9429463951 | ||
|
|
45f493da6c | ||
|
|
9e5ffd2ccf | ||
|
|
1051ea810a | ||
|
|
f20ae22ec6 | ||
|
|
13cd1b4bd9 | ||
|
|
c318f13895 | ||
|
|
1318e39fac | ||
|
|
c2b7fb29de | ||
|
|
aa6ac51f92 | ||
|
|
fea50e6840 | ||
|
|
746c9fc124 | ||
|
|
ee33097abe | ||
|
|
ab20175826 | ||
|
|
c506027aec | ||
|
|
7be0c8872e | ||
|
|
ce72367208 | ||
|
|
949f956378 | ||
|
|
a1eee8351e | ||
|
|
fea3f26e85 | ||
|
|
dd5b41f1d8 | ||
|
|
05dc5d915b | ||
|
|
65a229c79f | ||
|
|
84664d4b09 | ||
|
|
445170a76b | ||
|
|
c28b480b51 | ||
|
|
65716b60dd | ||
|
|
75f74a54bc | ||
|
|
ef41c626ef | ||
|
|
95ca08fdb7 | ||
|
|
053c2f3d32 | ||
|
|
57b3c14276 | ||
|
|
85c85bd2fb | ||
|
|
09ba7989c3 | ||
|
|
a115c31953 | ||
|
|
b5be37e5e7 | ||
|
|
c2255246a3 | ||
|
|
950d0d1d34 | ||
|
|
cdeba454b5 | ||
|
|
91ae93c167 | ||
|
|
35e6c49d7c | ||
|
|
e95b5b1dd6 | ||
|
|
23a2e5fee7 | ||
|
|
d01aa19ffa | ||
|
|
8cd7d5a753 | ||
|
|
42faa3ccf8 | ||
|
|
4694d60fc5 | ||
|
|
7dc689eee7 | ||
|
|
5df011ad4b | ||
|
|
200cf737bb | ||
|
|
d8a59e0d0d | ||
|
|
21533d9000 | ||
|
|
49979641fa | ||
|
|
43aa4001b5 | ||
|
|
7e087af6a6 | ||
|
|
927a1280f1 | ||
|
|
292e75d464 | ||
|
|
2c9376e50f | ||
|
|
062cdd0704 | ||
|
|
a713300c5b | ||
|
|
a15f0f301b | ||
|
|
23ce973377 | ||
|
|
685dea9e19 | ||
|
|
878be97668 | ||
|
|
6d6b1a384b | ||
|
|
fd22def4c5 | ||
|
|
1b60870f50 | ||
|
|
1c760b3826 | ||
|
|
9efaea6a1c | ||
|
|
3f72fe53ec | ||
|
|
40d125f0bb | ||
|
|
21d2f80fef | ||
|
|
6efdc90078 | ||
|
|
ad4147da54 | ||
|
|
69703cc882 | ||
|
|
4147e21a3a | ||
|
|
80c9b93b72 | ||
|
|
12e72a96c4 | ||
|
|
697a69a8c2 | ||
|
|
6e76d5e550 | ||
|
|
26779ceb20 | ||
|
|
23704ace1f | ||
|
|
b02370df2f | ||
|
|
bf7dbabca4 | ||
|
|
7256ee3701 | ||
|
|
0ed0bcd914 | ||
|
|
4d95761f66 | ||
|
|
b9dc323734 | ||
|
|
586492a3f0 | ||
|
|
f7e9bae571 | ||
|
|
1bc3c761de | ||
|
|
c80f7cf222 | ||
|
|
9578a5b2af | ||
|
|
cd5aa344f1 | ||
|
|
dc955c73a3 | ||
|
|
d4f8f87b03 | ||
|
|
0c3792d231 | ||
|
|
7695717546 | ||
|
|
6a070a0dd8 | ||
|
|
6481100bdf | ||
|
|
4b261286bf | ||
|
|
7e4a964b0a | ||
|
|
cea347d067 | ||
|
|
6054290d9a | ||
|
|
6d4adfe5c6 | ||
|
|
e7c7709b33 | ||
|
|
5c2e11017d | ||
|
|
e4331313f9 | ||
|
|
bc48600a49 | ||
|
|
0d6bb2f547 | ||
|
|
795a9d578a | ||
|
|
4a19e18999 | ||
|
|
4d59e06184 | ||
|
|
6891a525b3 | ||
|
|
0f7fd1362f | ||
|
|
de08c24b96 | ||
|
|
71e51ea42a | ||
|
|
0c5a104e0f | ||
|
|
606b898a71 | ||
|
|
e729494bd9 | ||
|
|
77080e8ca4 | ||
|
|
31ac1ed191 | ||
|
|
b8ff009b0a | ||
|
|
42167878fb | ||
|
|
6b15c7e422 | ||
|
|
abd56f514b | ||
|
|
707ebbf4d4 | ||
|
|
fc0cccf4a5 | ||
|
|
e36d4ea695 | ||
|
|
b358e47d71 | ||
|
|
38f038a5dc | ||
|
|
2291011dc7 | ||
|
|
89627fd23a |
70
.github/workflows/ci.yml
vendored
70
.github/workflows/ci.yml
vendored
@@ -7,29 +7,79 @@ on:
|
|||||||
branches: [ main ]
|
branches: [ main ]
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
build-and-test:
|
lint:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
- name: Use Node.js 18
|
- name: Use Node.js 18
|
||||||
uses: actions/setup-node@v4
|
uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node-version: '18'
|
node-version: '18'
|
||||||
cache: 'npm'
|
cache: 'npm'
|
||||||
|
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: npm ci
|
run: npm ci
|
||||||
|
- run: npm run build
|
||||||
- name: Run linting
|
- name: Run ESLint
|
||||||
run: npm run lint
|
run: npm run lint
|
||||||
|
- name: Ensure no changes
|
||||||
|
run: git diff --exit-code
|
||||||
|
|
||||||
|
test:
|
||||||
|
strategy:
|
||||||
|
fail-fast: false
|
||||||
|
matrix:
|
||||||
|
os: [ubuntu-latest, macos-latest, windows-latest]
|
||||||
|
runs-on: ${{ matrix.os }}
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- name: Use Node.js 18
|
||||||
|
uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
# https://github.com/microsoft/playwright-mcp/issues/344
|
||||||
|
node-version: '18.19'
|
||||||
|
cache: 'npm'
|
||||||
|
- name: Install dependencies
|
||||||
|
run: npm ci
|
||||||
|
- name: Playwright install
|
||||||
|
run: npx playwright install --with-deps
|
||||||
|
- name: Install MS Edge
|
||||||
|
# MS Edge is not preinstalled on macOS runners.
|
||||||
|
if: ${{ matrix.os == 'macos-latest' }}
|
||||||
|
run: npx playwright install msedge
|
||||||
- name: Build
|
- name: Build
|
||||||
run: npm run build
|
run: npm run build
|
||||||
|
|
||||||
- name: Install Playwright browsers
|
|
||||||
run: npx playwright install --with-deps
|
|
||||||
|
|
||||||
- name: Run tests
|
- name: Run tests
|
||||||
run: npm test
|
run: npm test
|
||||||
|
|
||||||
|
test_docker:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- name: Use Node.js 18
|
||||||
|
uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: '18'
|
||||||
|
cache: 'npm'
|
||||||
|
- name: Install dependencies
|
||||||
|
run: npm ci
|
||||||
|
- name: Playwright install
|
||||||
|
run: npx playwright install --with-deps chromium
|
||||||
|
- name: Build
|
||||||
|
run: npm run build
|
||||||
|
- name: Set up Docker Buildx
|
||||||
|
uses: docker/setup-buildx-action@v3
|
||||||
|
- name: Build and push
|
||||||
|
uses: docker/build-push-action@v6
|
||||||
|
with:
|
||||||
|
tags: playwright-mcp-dev:latest
|
||||||
|
cache-from: type=gha
|
||||||
|
cache-to: type=gha,mode=max
|
||||||
|
load: true
|
||||||
|
- name: Run tests
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
# Used for the Docker tests to share the test-results folder with the container.
|
||||||
|
umask 0000
|
||||||
|
npm run test -- --project=chromium-docker
|
||||||
|
env:
|
||||||
|
MCP_IN_DOCKER: 1
|
||||||
|
|||||||
53
.github/workflows/publish.yml
vendored
53
.github/workflows/publish.yml
vendored
@@ -7,7 +7,7 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
permissions:
|
permissions:
|
||||||
contents: read
|
contents: read
|
||||||
id-token: write
|
id-token: write # Needed for npm provenance
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
- uses: actions/setup-node@v4
|
- uses: actions/setup-node@v4
|
||||||
@@ -15,9 +15,56 @@ jobs:
|
|||||||
node-version: 18
|
node-version: 18
|
||||||
registry-url: https://registry.npmjs.org/
|
registry-url: https://registry.npmjs.org/
|
||||||
- run: npm ci
|
- run: npm ci
|
||||||
|
- run: npx playwright install --with-deps
|
||||||
- run: npm run build
|
- run: npm run build
|
||||||
- run: npm run lint
|
- run: npm run lint
|
||||||
- run: npm run test
|
- run: npm run ctest
|
||||||
- run: npm publish --provenance
|
- run: npm publish --provenance
|
||||||
env:
|
env:
|
||||||
NODE_AUTH_TOKEN: ${{secrets.NPM_TOKEN}}
|
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
|
||||||
|
|
||||||
|
publish-docker:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
id-token: write # Needed for OIDC login to Azure
|
||||||
|
environment: allow-publishing-docker-to-acr
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- name: Set up QEMU # Needed for multi-platform builds (e.g., arm64 on amd64 runner)
|
||||||
|
uses: docker/setup-qemu-action@v3
|
||||||
|
- name: Set up Docker Buildx # Needed for multi-platform builds
|
||||||
|
uses: docker/setup-buildx-action@v3
|
||||||
|
- name: Azure Login via OIDC
|
||||||
|
uses: azure/login@v2
|
||||||
|
with:
|
||||||
|
client-id: ${{ secrets.AZURE_DOCKER_CLIENT_ID }}
|
||||||
|
tenant-id: ${{ secrets.AZURE_DOCKER_TENANT_ID }}
|
||||||
|
subscription-id: ${{ secrets.AZURE_DOCKER_SUBSCRIPTION_ID }}
|
||||||
|
- name: Login to ACR
|
||||||
|
run: az acr login --name playwright
|
||||||
|
- name: Build and push Docker image
|
||||||
|
id: build-push
|
||||||
|
uses: docker/build-push-action@v6
|
||||||
|
with:
|
||||||
|
context: .
|
||||||
|
file: ./Dockerfile # Adjust path if your Dockerfile is elsewhere
|
||||||
|
platforms: linux/amd64,linux/arm64
|
||||||
|
push: true
|
||||||
|
tags: |
|
||||||
|
playwright.azurecr.io/public/playwright/mcp:${{ github.event.release.tag_name }}
|
||||||
|
playwright.azurecr.io/public/playwright/mcp:latest
|
||||||
|
- uses: oras-project/setup-oras@v1
|
||||||
|
- name: Set oras tags
|
||||||
|
run: |
|
||||||
|
attach_eol_manifest() {
|
||||||
|
local image="$1"
|
||||||
|
local today=$(date -u +'%Y-%m-%d')
|
||||||
|
# oras is re-using Docker credentials, so we don't need to login.
|
||||||
|
# Following the advice in https://portal.microsofticm.com/imp/v3/incidents/incident/476783820/summary
|
||||||
|
oras attach --artifact-type application/vnd.microsoft.artifact.lifecycle --annotation "vnd.microsoft.artifact.lifecycle.end-of-life.date=$today" $image
|
||||||
|
}
|
||||||
|
# for each tag, attach the eol manifest
|
||||||
|
for tag in $(echo ${{ steps.build-push.outputs.metadata['image.name'] }} | tr ',' '\n'); do
|
||||||
|
attach_eol_manifest $tag
|
||||||
|
done
|
||||||
|
|||||||
5
.gitignore
vendored
5
.gitignore
vendored
@@ -1,3 +1,8 @@
|
|||||||
lib/
|
lib/
|
||||||
node_modules/
|
node_modules/
|
||||||
test-results/
|
test-results/
|
||||||
|
playwright-report/
|
||||||
|
.vscode/mcp.json
|
||||||
|
|
||||||
|
.idea
|
||||||
|
.DS_Store
|
||||||
|
|||||||
@@ -4,3 +4,4 @@ LICENSE
|
|||||||
!lib/**/*.js
|
!lib/**/*.js
|
||||||
!cli.js
|
!cli.js
|
||||||
!index.*
|
!index.*
|
||||||
|
!config.d.ts
|
||||||
|
|||||||
69
Dockerfile
Normal file
69
Dockerfile
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
ARG PLAYWRIGHT_BROWSERS_PATH=/ms-playwright
|
||||||
|
|
||||||
|
# ------------------------------
|
||||||
|
# Base
|
||||||
|
# ------------------------------
|
||||||
|
# Base stage: Contains only the minimal dependencies required for runtime
|
||||||
|
# (node_modules and Playwright system dependencies)
|
||||||
|
FROM node:22-bookworm-slim AS base
|
||||||
|
|
||||||
|
ARG PLAYWRIGHT_BROWSERS_PATH
|
||||||
|
ENV PLAYWRIGHT_BROWSERS_PATH=${PLAYWRIGHT_BROWSERS_PATH}
|
||||||
|
|
||||||
|
# Set the working directory
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
RUN --mount=type=cache,target=/root/.npm,sharing=locked,id=npm-cache \
|
||||||
|
--mount=type=bind,source=package.json,target=package.json \
|
||||||
|
--mount=type=bind,source=package-lock.json,target=package-lock.json \
|
||||||
|
npm ci --omit=dev && \
|
||||||
|
# Install system dependencies for playwright
|
||||||
|
npx -y playwright-core install-deps chromium
|
||||||
|
|
||||||
|
# ------------------------------
|
||||||
|
# Builder
|
||||||
|
# ------------------------------
|
||||||
|
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 \
|
||||||
|
npm ci
|
||||||
|
|
||||||
|
# Copy the rest of the app
|
||||||
|
COPY *.json *.js *.ts .
|
||||||
|
COPY src src/
|
||||||
|
|
||||||
|
# Build the app
|
||||||
|
RUN npm run build
|
||||||
|
|
||||||
|
# ------------------------------
|
||||||
|
# Browser
|
||||||
|
# ------------------------------
|
||||||
|
# Cache optimization:
|
||||||
|
# - Browser is downloaded only when node_modules or Playwright system dependencies change
|
||||||
|
# - Cache is reused when only source code changes
|
||||||
|
FROM base AS browser
|
||||||
|
|
||||||
|
RUN npx -y playwright-core install --no-shell chromium
|
||||||
|
|
||||||
|
# ------------------------------
|
||||||
|
# Runtime
|
||||||
|
# ------------------------------
|
||||||
|
FROM base
|
||||||
|
|
||||||
|
ARG PLAYWRIGHT_BROWSERS_PATH
|
||||||
|
ARG USERNAME=node
|
||||||
|
ENV NODE_ENV=production
|
||||||
|
|
||||||
|
# Set the correct ownership for the runtime user on production `node_modules`
|
||||||
|
RUN chown -R ${USERNAME}:${USERNAME} node_modules
|
||||||
|
|
||||||
|
USER ${USERNAME}
|
||||||
|
|
||||||
|
COPY --from=browser --chown=${USERNAME}:${USERNAME} ${PLAYWRIGHT_BROWSERS_PATH} ${PLAYWRIGHT_BROWSERS_PATH}
|
||||||
|
COPY --chown=${USERNAME}:${USERNAME} cli.js package.json ./
|
||||||
|
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"]
|
||||||
779
README.md
779
README.md
@@ -4,18 +4,22 @@ A Model Context Protocol (MCP) server that provides browser automation capabilit
|
|||||||
|
|
||||||
### Key Features
|
### Key Features
|
||||||
|
|
||||||
- **Fast and lightweight**: Uses Playwright's accessibility tree, not pixel-based input.
|
- **Fast and lightweight**. Uses Playwright's accessibility tree, not pixel-based input.
|
||||||
- **LLM-friendly**: No vision models needed, operates purely on structured data.
|
- **LLM-friendly**. No vision models needed, operates purely on structured data.
|
||||||
- **Deterministic tool application**: Avoids ambiguity common with screenshot-based approaches.
|
- **Deterministic tool application**. Avoids ambiguity common with screenshot-based approaches.
|
||||||
|
|
||||||
### Use Cases
|
### Requirements
|
||||||
|
- Node.js 18 or newer
|
||||||
|
- VS Code, Cursor, Windsurf, Claude Desktop, Goose or any other MCP client
|
||||||
|
|
||||||
- Web navigation and form-filling
|
<!--
|
||||||
- Data extraction from structured content
|
// Generate using:
|
||||||
- Automated testing driven by LLMs
|
node utils/generate-links.js
|
||||||
- General-purpose browser interaction for agents
|
-->
|
||||||
|
|
||||||
### Example config
|
### Getting started
|
||||||
|
|
||||||
|
First, install the Playwright MCP server with your client. A typical configuration looks like this:
|
||||||
|
|
||||||
```js
|
```js
|
||||||
{
|
{
|
||||||
@@ -30,67 +34,231 @@ A Model Context Protocol (MCP) server that provides browser automation capabilit
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
[<img src="https://img.shields.io/badge/VS_Code-VS_Code?style=flat-square&label=Install%20Server&color=0098FF" alt="Install in VS Code">](https://insiders.vscode.dev/redirect?url=vscode%3Amcp%2Finstall%3F%257B%2522name%2522%253A%2522playwright%2522%252C%2522command%2522%253A%2522npx%2522%252C%2522args%2522%253A%255B%2522%2540playwright%252Fmcp%2540latest%2522%255D%257D) [<img alt="Install in VS Code Insiders" src="https://img.shields.io/badge/VS_Code_Insiders-VS_Code_Insiders?style=flat-square&label=Install%20Server&color=24bfa5">](https://insiders.vscode.dev/redirect?url=vscode-insiders%3Amcp%2Finstall%3F%257B%2522name%2522%253A%2522playwright%2522%252C%2522command%2522%253A%2522npx%2522%252C%2522args%2522%253A%255B%2522%2540playwright%252Fmcp%2540latest%2522%255D%257D)
|
||||||
|
|
||||||
#### Installation in VS Code
|
|
||||||
|
|
||||||
Install the Playwright MCP server in VS Code using one of these buttons:
|
<details><summary><b>Install in VS Code</b></summary>
|
||||||
|
|
||||||
<!--
|
You can also install the Playwright MCP server using the VS Code CLI:
|
||||||
// Generate using?:
|
|
||||||
const config = JSON.stringify({ name: 'playwright', command: 'npx', args: ["-y", "@playwright/mcp@latest"] });
|
|
||||||
const urlForWebsites = `vscode:mcp/install?${encodeURIComponent(config)}`;
|
|
||||||
// Github markdown does not allow linking to `vscode:` directly, so you can use our redirect:
|
|
||||||
const urlForGithub = `https://insiders.vscode.dev/redirect?url=${encodeURIComponent(urlForWebsites)}`;
|
|
||||||
-->
|
|
||||||
|
|
||||||
[<img alt="Install in VS Code Insiders" src="https://img.shields.io/badge/VS_Code_Insiders-VS_Code_Insiders?style=flat-square&label=Install%20Server&color=24bfa5">](https://insiders.vscode.dev/redirect?url=vscode-insiders%3Amcp%2Finstall%3F%257B%2522name%2522%253A%2522playwright%2522%252C%2522command%2522%253A%2522npx%2522%252C%2522args%2522%253A%255B%2522-y%2522%252C%2522%2540playwright%252Fmcp%2540latest%2522%255D%257D)
|
|
||||||
|
|
||||||
Alternatively, you can install the Playwright MCP server using the VS Code CLI:
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# For VS Code
|
# For VS Code
|
||||||
code --add-mcp '{"name":"playwright","command":"npx","args":["@playwright/mcp@latest"]}'
|
code --add-mcp '{"name":"playwright","command":"npx","args":["@playwright/mcp@latest"]}'
|
||||||
```
|
```
|
||||||
|
|
||||||
```bash
|
|
||||||
# For VS Code Insiders
|
|
||||||
code-insiders --add-mcp '{"name":"playwright","command":"npx","args":["@playwright/mcp@latest"]}'
|
|
||||||
```
|
|
||||||
|
|
||||||
After installation, the Playwright MCP server will be available for use with your GitHub Copilot agent in VS Code.
|
After installation, the Playwright MCP server will be available for use with your GitHub Copilot agent in VS Code.
|
||||||
|
</details>
|
||||||
|
|
||||||
### CLI Options
|
<details>
|
||||||
|
<summary><b>Install in Cursor</b></summary>
|
||||||
|
|
||||||
The Playwright MCP server supports the following command-line options:
|
#### Click the button to install:
|
||||||
|
|
||||||
- `--browser <browser>`: Browser or chrome channel to use. Possible values:
|
[](https://cursor.com/install-mcp?name=playwright&config=eyJjb21tYW5kIjoibnB4IEBwbGF5d3JpZ2h0L21jcEBsYXRlc3QifQ%3D%3D)
|
||||||
- `chrome`, `firefox`, `webkit`, `msedge`
|
|
||||||
- Chrome channels: `chrome-beta`, `chrome-canary`, `chrome-dev`
|
|
||||||
- Edge channels: `msedge-beta`, `msedge-canary`, `msedge-dev`
|
|
||||||
- Default: `chrome`
|
|
||||||
- `--cdp-endpoint <endpoint>`: CDP endpoint to connect to
|
|
||||||
- `--executable-path <path>`: Path to the browser executable
|
|
||||||
- `--headless`: Run browser in headless mode (headed by default)
|
|
||||||
- `--port <port>`: Port to listen on for SSE transport
|
|
||||||
- `--user-data-dir <path>`: Path to the user data directory
|
|
||||||
- `--vision`: Run server that uses screenshots (Aria snapshots are used by default)
|
|
||||||
|
|
||||||
### User data directory
|
#### Or install manually:
|
||||||
|
|
||||||
Playwright MCP will launch the browser with the new profile, located at
|
Go to `Cursor Settings` -> `MCP` -> `Add new MCP Server`. Name to your liking, use `command` type with the command `npx @playwright/mcp`. You can also verify config or add command like arguments via clicking `Edit`.
|
||||||
|
|
||||||
|
```js
|
||||||
|
{
|
||||||
|
"mcpServers": {
|
||||||
|
"playwright": {
|
||||||
|
"command": "npx",
|
||||||
|
"args": [
|
||||||
|
"@playwright/mcp@latest"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
```
|
```
|
||||||
- `%USERPROFILE%\AppData\Local\ms-playwright\mcp-chrome-profile` on Windows
|
</details>
|
||||||
- `~/Library/Caches/ms-playwright/mcp-chrome-profile` on macOS
|
|
||||||
- `~/.cache/ms-playwright/mcp-chrome-profile` on Linux
|
<details>
|
||||||
|
<summary><b>Install in Windsurf</b></summary>
|
||||||
|
|
||||||
|
Follow Windsurf MCP [documentation](https://docs.windsurf.com/windsurf/cascade/mcp). Use following configuration:
|
||||||
|
|
||||||
|
```js
|
||||||
|
{
|
||||||
|
"mcpServers": {
|
||||||
|
"playwright": {
|
||||||
|
"command": "npx",
|
||||||
|
"args": [
|
||||||
|
"@playwright/mcp@latest"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
</details>
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary><b>Install in Claude Desktop</b></summary>
|
||||||
|
|
||||||
|
Follow the MCP install [guide](https://modelcontextprotocol.io/quickstart/user), use following configuration:
|
||||||
|
|
||||||
|
```js
|
||||||
|
{
|
||||||
|
"mcpServers": {
|
||||||
|
"playwright": {
|
||||||
|
"command": "npx",
|
||||||
|
"args": [
|
||||||
|
"@playwright/mcp@latest"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
</details>
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary><b>Install in Claude Code</b></summary>
|
||||||
|
|
||||||
|
Use the Claude Code CLI to add the Playwright MCP server:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
claude mcp add playwright npx @playwright/mcp@latest
|
||||||
|
```
|
||||||
|
</details>
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary><b>Install in Goose</b></summary>
|
||||||
|
|
||||||
|
#### Click the button to install:
|
||||||
|
|
||||||
|
[](https://block.github.io/goose/extension?cmd=npx&arg=%40playwright%2Fmcp%40latest&id=playwright&name=Playwright&description=Interact%20with%20web%20pages%20through%20structured%20accessibility%20snapshots%20using%20Playwright)
|
||||||
|
|
||||||
|
#### Or install manually:
|
||||||
|
|
||||||
|
Go to `Advanced settings` -> `Extensions` -> `Add custom extension`. Name to your liking, use type `STDIO`, and set the `command` to `npx @playwright/mcp`. Click "Add Extension".
|
||||||
|
</details>
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary><b>Install in Qodo Gen</b></summary>
|
||||||
|
|
||||||
|
Open [Qodo Gen](https://docs.qodo.ai/qodo-documentation/qodo-gen) chat panel in VSCode or IntelliJ → Connect more tools → + Add new MCP → Paste the following configuration:
|
||||||
|
|
||||||
|
```js
|
||||||
|
{
|
||||||
|
"mcpServers": {
|
||||||
|
"playwright": {
|
||||||
|
"command": "npx",
|
||||||
|
"args": [
|
||||||
|
"@playwright/mcp@latest"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
All the logged in information will be stored in that profile, you can delete it between sessions if you'd like to clear the offline state.
|
Click <code>Save</code>.
|
||||||
|
</details>
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary><b>Install in Gemini CLI</b></summary>
|
||||||
|
|
||||||
### Running headless browser (Browser without GUI).
|
Follow the MCP install [guide](https://github.com/google-gemini/gemini-cli/blob/main/docs/tools/mcp-server.md#configure-the-mcp-server-in-settingsjson), use following configuration:
|
||||||
|
|
||||||
This mode is useful for background or batch operations.
|
```js
|
||||||
|
{
|
||||||
|
"mcpServers": {
|
||||||
|
"playwright": {
|
||||||
|
"command": "npx",
|
||||||
|
"args": [
|
||||||
|
"@playwright/mcp@latest"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
</details>
|
||||||
|
|
||||||
|
### Configuration
|
||||||
|
|
||||||
|
Playwright MCP server supports following arguments. They can be provided in the JSON configuration above, as a part of the `"args"` list:
|
||||||
|
|
||||||
|
<!--- Options generated by update-readme.js -->
|
||||||
|
|
||||||
|
```
|
||||||
|
> npx @playwright/mcp@latest --help
|
||||||
|
--allowed-origins <origins> semicolon-separated list of origins to allow the
|
||||||
|
browser to request. Default is to allow all.
|
||||||
|
--blocked-origins <origins> semicolon-separated list of origins to block the
|
||||||
|
browser from requesting. Blocklist is evaluated
|
||||||
|
before allowlist. If used without the allowlist,
|
||||||
|
requests not matching the blocklist are still
|
||||||
|
allowed.
|
||||||
|
--block-service-workers block service workers
|
||||||
|
--browser <browser> browser or chrome channel to use, possible
|
||||||
|
values: chrome, firefox, webkit, msedge.
|
||||||
|
--browser-agent <endpoint> Use browser agent (experimental).
|
||||||
|
--caps <caps> comma-separated list of capabilities to enable,
|
||||||
|
possible values: tabs, pdf, history, wait, files,
|
||||||
|
install. Default is all.
|
||||||
|
--cdp-endpoint <endpoint> CDP endpoint to connect to.
|
||||||
|
--config <path> path to the configuration file.
|
||||||
|
--device <device> device to emulate, for example: "iPhone 15"
|
||||||
|
--executable-path <path> path to the browser executable.
|
||||||
|
--headless run browser in headless mode, headed by default
|
||||||
|
--host <host> host to bind server to. Default is localhost. Use
|
||||||
|
0.0.0.0 to bind to all interfaces.
|
||||||
|
--ignore-https-errors ignore https errors
|
||||||
|
--isolated keep the browser profile in memory, do not save
|
||||||
|
it to disk.
|
||||||
|
--image-responses <mode> whether to send image responses to the client.
|
||||||
|
Can be "allow", "omit", or "auto". Defaults to
|
||||||
|
"auto", which sends images if the client can
|
||||||
|
display them.
|
||||||
|
--no-sandbox disable the sandbox for all process types that
|
||||||
|
are normally sandboxed.
|
||||||
|
--output-dir <path> path to the directory for output files.
|
||||||
|
--port <port> port to listen on for SSE transport.
|
||||||
|
--proxy-bypass <bypass> comma-separated domains to bypass proxy, for
|
||||||
|
example ".com,chromium.org,.domain.com"
|
||||||
|
--proxy-server <proxy> specify proxy server, for example
|
||||||
|
"http://myproxy:3128" or "socks5://myproxy:8080"
|
||||||
|
--save-trace Whether to save the Playwright Trace of the
|
||||||
|
session into the output directory.
|
||||||
|
--storage-state <path> path to the storage state file for isolated
|
||||||
|
sessions.
|
||||||
|
--user-agent <ua string> specify user agent string
|
||||||
|
--user-data-dir <path> path to the user data directory. If not
|
||||||
|
specified, a temporary directory will be created.
|
||||||
|
--viewport-size <size> specify browser viewport size in pixels, for
|
||||||
|
example "1280, 720"
|
||||||
|
--vision Run server that uses screenshots (Aria snapshots
|
||||||
|
are used by default)
|
||||||
|
```
|
||||||
|
|
||||||
|
<!--- End of options generated section -->
|
||||||
|
|
||||||
|
### User profile
|
||||||
|
|
||||||
|
You can run Playwright MCP with persistent profile like a regular browser (default), or in the isolated contexts for the testing sessions.
|
||||||
|
|
||||||
|
**Persistent profile**
|
||||||
|
|
||||||
|
All the logged in information will be stored in the persistent profile, you can delete it between sessions if you'd like to clear the offline state.
|
||||||
|
Persistent profile is located at the following locations and you can override it with the `--user-data-dir` argument.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Windows
|
||||||
|
%USERPROFILE%\AppData\Local\ms-playwright\mcp-{channel}-profile
|
||||||
|
|
||||||
|
# macOS
|
||||||
|
- ~/Library/Caches/ms-playwright/mcp-{channel}-profile
|
||||||
|
|
||||||
|
# Linux
|
||||||
|
- ~/.cache/ms-playwright/mcp-{channel}-profile
|
||||||
|
```
|
||||||
|
|
||||||
|
**Isolated**
|
||||||
|
|
||||||
|
In the isolated mode, each session is started in the isolated profile. Every time you ask MCP to close the browser,
|
||||||
|
the session is closed and all the storage state for this session is lost. You can provide initial storage state
|
||||||
|
to the browser via the config's `contextOptions` or via the `--storage-state` argument. Learn more about the storage
|
||||||
|
state [here](https://playwright.dev/docs/auth).
|
||||||
|
|
||||||
```js
|
```js
|
||||||
{
|
{
|
||||||
@@ -99,14 +267,105 @@ This mode is useful for background or batch operations.
|
|||||||
"command": "npx",
|
"command": "npx",
|
||||||
"args": [
|
"args": [
|
||||||
"@playwright/mcp@latest",
|
"@playwright/mcp@latest",
|
||||||
"--headless"
|
"--isolated",
|
||||||
|
"--storage-state={path/to/storage.json}"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
### Running headed browser on Linux w/o DISPLAY
|
### Configuration file
|
||||||
|
|
||||||
|
The Playwright MCP server can be configured using a JSON configuration file. You can specify the configuration file
|
||||||
|
using the `--config` command line option:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npx @playwright/mcp@latest --config path/to/config.json
|
||||||
|
```
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary>Configuration file schema</summary>
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
{
|
||||||
|
// Browser configuration
|
||||||
|
browser?: {
|
||||||
|
// Browser type to use (chromium, firefox, or webkit)
|
||||||
|
browserName?: 'chromium' | 'firefox' | 'webkit';
|
||||||
|
|
||||||
|
// Keep the browser profile in memory, do not save it to disk.
|
||||||
|
isolated?: boolean;
|
||||||
|
|
||||||
|
// Path to user data directory for browser profile persistence
|
||||||
|
userDataDir?: string;
|
||||||
|
|
||||||
|
// Browser launch options (see Playwright docs)
|
||||||
|
// @see https://playwright.dev/docs/api/class-browsertype#browser-type-launch
|
||||||
|
launchOptions?: {
|
||||||
|
channel?: string; // Browser channel (e.g. 'chrome')
|
||||||
|
headless?: boolean; // Run in headless mode
|
||||||
|
executablePath?: string; // Path to browser executable
|
||||||
|
// ... other Playwright launch options
|
||||||
|
};
|
||||||
|
|
||||||
|
// Browser context options
|
||||||
|
// @see https://playwright.dev/docs/api/class-browser#browser-new-context
|
||||||
|
contextOptions?: {
|
||||||
|
viewport?: { width: number, height: number };
|
||||||
|
// ... other Playwright context options
|
||||||
|
};
|
||||||
|
|
||||||
|
// CDP endpoint for connecting to existing browser
|
||||||
|
cdpEndpoint?: string;
|
||||||
|
|
||||||
|
// Remote Playwright server endpoint
|
||||||
|
remoteEndpoint?: string;
|
||||||
|
},
|
||||||
|
|
||||||
|
// Server configuration
|
||||||
|
server?: {
|
||||||
|
port?: number; // Port to listen on
|
||||||
|
host?: string; // Host to bind to (default: localhost)
|
||||||
|
},
|
||||||
|
|
||||||
|
// List of enabled capabilities
|
||||||
|
capabilities?: Array<
|
||||||
|
'core' | // Core browser automation
|
||||||
|
'tabs' | // Tab management
|
||||||
|
'pdf' | // PDF generation
|
||||||
|
'history' | // Browser history
|
||||||
|
'wait' | // Wait utilities
|
||||||
|
'files' | // File handling
|
||||||
|
'install' | // Browser installation
|
||||||
|
'testing' // Testing
|
||||||
|
>;
|
||||||
|
|
||||||
|
// Enable vision mode (screenshots instead of accessibility snapshots)
|
||||||
|
vision?: boolean;
|
||||||
|
|
||||||
|
// Directory for output files
|
||||||
|
outputDir?: string;
|
||||||
|
|
||||||
|
// Network configuration
|
||||||
|
network?: {
|
||||||
|
// List of origins to allow the browser to request. Default is to allow all. Origins matching both `allowedOrigins` and `blockedOrigins` will be blocked.
|
||||||
|
allowedOrigins?: string[];
|
||||||
|
|
||||||
|
// List of origins to block the browser to request. Origins matching both `allowedOrigins` and `blockedOrigins` will be blocked.
|
||||||
|
blockedOrigins?: string[];
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether to send image responses to the client. Can be "allow", "omit", or "auto".
|
||||||
|
* Defaults to "auto", images are omitted for Cursor clients and sent for all other clients.
|
||||||
|
*/
|
||||||
|
imageResponses?: 'allow' | 'omit' | 'auto';
|
||||||
|
}
|
||||||
|
```
|
||||||
|
</details>
|
||||||
|
|
||||||
|
### Standalone MCP server
|
||||||
|
|
||||||
When running headed browser on system w/o display or from worker processes of the IDEs,
|
When running headed browser on system w/o display or from worker processes of the IDEs,
|
||||||
run the MCP server from environment with the DISPLAY and pass the `--port` flag to enable SSE transport.
|
run the MCP server from environment with the DISPLAY and pass the `--port` flag to enable SSE transport.
|
||||||
@@ -127,7 +386,52 @@ And then in MCP client config, set the `url` to the SSE endpoint:
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
### Tool Modes
|
<details>
|
||||||
|
<summary><b>Docker</b></summary>
|
||||||
|
|
||||||
|
**NOTE:** The Docker implementation only supports headless chromium at the moment.
|
||||||
|
|
||||||
|
```js
|
||||||
|
{
|
||||||
|
"mcpServers": {
|
||||||
|
"playwright": {
|
||||||
|
"command": "docker",
|
||||||
|
"args": ["run", "-i", "--rm", "--init", "--pull=always", "mcr.microsoft.com/playwright/mcp"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
You can build the Docker image yourself.
|
||||||
|
|
||||||
|
```
|
||||||
|
docker build -t mcr.microsoft.com/playwright/mcp .
|
||||||
|
```
|
||||||
|
</details>
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary><b>Programmatic usage</b></summary>
|
||||||
|
|
||||||
|
```js
|
||||||
|
import http from 'http';
|
||||||
|
|
||||||
|
import { createConnection } from '@playwright/mcp';
|
||||||
|
import { SSEServerTransport } from '@modelcontextprotocol/sdk/server/sse.js';
|
||||||
|
|
||||||
|
http.createServer(async (req, res) => {
|
||||||
|
// ...
|
||||||
|
|
||||||
|
// Creates a headless Playwright MCP server with SSE transport
|
||||||
|
const connection = await createConnection({ browser: { launchOptions: { headless: true } } });
|
||||||
|
const transport = new SSEServerTransport('/messages', res);
|
||||||
|
await connection.sever.connect(transport);
|
||||||
|
|
||||||
|
// ...
|
||||||
|
});
|
||||||
|
```
|
||||||
|
</details>
|
||||||
|
|
||||||
|
### Tools
|
||||||
|
|
||||||
The tools are available in two modes:
|
The tools are available in two modes:
|
||||||
|
|
||||||
@@ -153,171 +457,372 @@ To use Vision Mode, add the `--vision` flag when starting the server:
|
|||||||
Vision Mode works best with the computer use models that are able to interact with elements using
|
Vision Mode works best with the computer use models that are able to interact with elements using
|
||||||
X Y coordinate space, based on the provided screenshot.
|
X Y coordinate space, based on the provided screenshot.
|
||||||
|
|
||||||
### Programmatic usage with custom transports
|
<!--- Tools generated by update-readme.js -->
|
||||||
|
|
||||||
```js
|
<details>
|
||||||
import { createServer } from '@playwright/mcp';
|
<summary><b>Interactions</b></summary>
|
||||||
|
|
||||||
// ...
|
<!-- NOTE: This has been generated via update-readme.js -->
|
||||||
|
|
||||||
const server = createServer({
|
- **browser_snapshot**
|
||||||
launchOptions: { headless: true }
|
- Title: Page snapshot
|
||||||
});
|
- Description: Capture accessibility snapshot of the current page, this is better than screenshot
|
||||||
transport = new SSEServerTransport("/messages", res);
|
|
||||||
server.connect(transport);
|
|
||||||
```
|
|
||||||
|
|
||||||
### Snapshot Mode
|
|
||||||
|
|
||||||
The Playwright MCP provides a set of tools for browser automation. Here are all available tools:
|
|
||||||
|
|
||||||
- **browser_navigate**
|
|
||||||
- Description: Navigate to a URL
|
|
||||||
- Parameters:
|
|
||||||
- `url` (string): The URL to navigate to
|
|
||||||
|
|
||||||
- **browser_go_back**
|
|
||||||
- Description: Go back to the previous page
|
|
||||||
- Parameters: None
|
- Parameters: None
|
||||||
|
- Read-only: **true**
|
||||||
|
|
||||||
- **browser_go_forward**
|
<!-- NOTE: This has been generated via update-readme.js -->
|
||||||
- Description: Go forward to the next page
|
|
||||||
- Parameters: None
|
|
||||||
|
|
||||||
- **browser_click**
|
- **browser_click**
|
||||||
|
- Title: Click
|
||||||
- Description: Perform click on a web page
|
- Description: Perform click on a web page
|
||||||
- Parameters:
|
- Parameters:
|
||||||
- `element` (string): Human-readable element description used to obtain permission to interact with the element
|
- `element` (string): Human-readable element description used to obtain permission to interact with the element
|
||||||
- `ref` (string): Exact target element reference from the page snapshot
|
- `ref` (string): Exact target element reference from the page snapshot
|
||||||
|
- `doubleClick` (boolean, optional): Whether to perform a double click instead of a single click
|
||||||
|
- Read-only: **false**
|
||||||
|
|
||||||
|
<!-- NOTE: This has been generated via update-readme.js -->
|
||||||
|
|
||||||
|
- **browser_drag**
|
||||||
|
- Title: Drag mouse
|
||||||
|
- Description: Perform drag and drop between two elements
|
||||||
|
- Parameters:
|
||||||
|
- `startElement` (string): Human-readable source element description used to obtain the permission to interact with the element
|
||||||
|
- `startRef` (string): Exact source element reference from the page snapshot
|
||||||
|
- `endElement` (string): Human-readable target element description used to obtain the permission to interact with the element
|
||||||
|
- `endRef` (string): Exact target element reference from the page snapshot
|
||||||
|
- Read-only: **false**
|
||||||
|
|
||||||
|
<!-- NOTE: This has been generated via update-readme.js -->
|
||||||
|
|
||||||
- **browser_hover**
|
- **browser_hover**
|
||||||
|
- Title: Hover mouse
|
||||||
- Description: Hover over element on page
|
- Description: Hover over element on page
|
||||||
- Parameters:
|
- Parameters:
|
||||||
- `element` (string): Human-readable element description used to obtain permission to interact with the element
|
- `element` (string): Human-readable element description used to obtain permission to interact with the element
|
||||||
- `ref` (string): Exact target element reference from the page snapshot
|
- `ref` (string): Exact target element reference from the page snapshot
|
||||||
|
- Read-only: **true**
|
||||||
|
|
||||||
- **browser_drag**
|
<!-- NOTE: This has been generated via update-readme.js -->
|
||||||
- Description: Perform drag and drop between two elements
|
|
||||||
- Parameters:
|
|
||||||
- `startElement` (string): Human-readable source element description used to obtain permission to interact with the element
|
|
||||||
- `startRef` (string): Exact source element reference from the page snapshot
|
|
||||||
- `endElement` (string): Human-readable target element description used to obtain permission to interact with the element
|
|
||||||
- `endRef` (string): Exact target element reference from the page snapshot
|
|
||||||
|
|
||||||
- **browser_type**
|
- **browser_type**
|
||||||
|
- Title: Type text
|
||||||
- Description: Type text into editable element
|
- Description: Type text into editable element
|
||||||
- Parameters:
|
- Parameters:
|
||||||
- `element` (string): Human-readable element description used to obtain permission to interact with the element
|
- `element` (string): Human-readable element description used to obtain permission to interact with the element
|
||||||
- `ref` (string): Exact target element reference from the page snapshot
|
- `ref` (string): Exact target element reference from the page snapshot
|
||||||
- `text` (string): Text to type into the element
|
- `text` (string): Text to type into the element
|
||||||
- `submit` (boolean): Whether to submit entered text (press Enter after)
|
- `submit` (boolean, optional): Whether to submit entered text (press Enter after)
|
||||||
|
- `slowly` (boolean, optional): Whether to type one character at a time. Useful for triggering key handlers in the page. By default entire text is filled in at once.
|
||||||
|
- Read-only: **false**
|
||||||
|
|
||||||
|
<!-- NOTE: This has been generated via update-readme.js -->
|
||||||
|
|
||||||
- **browser_select_option**
|
- **browser_select_option**
|
||||||
- Description: Select option in a dropdown
|
- Title: Select option
|
||||||
|
- Description: Select an option in a dropdown
|
||||||
- Parameters:
|
- Parameters:
|
||||||
- `element` (string): Human-readable element description used to obtain permission to interact with the element
|
- `element` (string): Human-readable element description used to obtain permission to interact with the element
|
||||||
- `ref` (string): Exact target element reference from the page snapshot
|
- `ref` (string): Exact target element reference from the page snapshot
|
||||||
- `values` (array): Array of values to select in the dropdown.
|
- `values` (array): Array of values to select in the dropdown. This can be a single value or multiple values.
|
||||||
|
- Read-only: **false**
|
||||||
|
|
||||||
- **browser_choose_file**
|
<!-- NOTE: This has been generated via update-readme.js -->
|
||||||
- Description: Choose one or multiple files to upload
|
|
||||||
- Parameters:
|
|
||||||
- `paths` (array): The absolute paths to the files to upload. Can be a single file or multiple files.
|
|
||||||
|
|
||||||
- **browser_press_key**
|
- **browser_press_key**
|
||||||
|
- Title: Press a key
|
||||||
- Description: Press a key on the keyboard
|
- Description: Press a key on the keyboard
|
||||||
- Parameters:
|
- Parameters:
|
||||||
- `key` (string): Name of the key to press or a character to generate, such as `ArrowLeft` or `a`
|
- `key` (string): Name of the key to press or a character to generate, such as `ArrowLeft` or `a`
|
||||||
|
- Read-only: **false**
|
||||||
|
|
||||||
- **browser_snapshot**
|
<!-- NOTE: This has been generated via update-readme.js -->
|
||||||
- Description: Capture accessibility snapshot of the current page (better than screenshot)
|
|
||||||
- Parameters: None
|
|
||||||
|
|
||||||
- **browser_save_as_pdf**
|
- **browser_wait_for**
|
||||||
- Description: Save page as PDF
|
- Title: Wait for
|
||||||
- Parameters: None
|
- Description: Wait for text to appear or disappear or a specified time to pass
|
||||||
|
|
||||||
- **browser_take_screenshot**
|
|
||||||
- Description: Capture screenshot of the page
|
|
||||||
- Parameters:
|
- Parameters:
|
||||||
- `raw` (string): Optionally returns lossless PNG screenshot. JPEG by default.
|
- `time` (number, optional): The time to wait in seconds
|
||||||
|
- `text` (string, optional): The text to wait for
|
||||||
|
- `textGone` (string, optional): The text to wait for to disappear
|
||||||
|
- Read-only: **true**
|
||||||
|
|
||||||
- **browser_wait**
|
<!-- NOTE: This has been generated via update-readme.js -->
|
||||||
- Description: Wait for a specified time in seconds
|
|
||||||
|
- **browser_file_upload**
|
||||||
|
- Title: Upload files
|
||||||
|
- Description: Upload one or multiple files
|
||||||
- Parameters:
|
- Parameters:
|
||||||
- `time` (number): The time to wait in seconds (capped at 10 seconds)
|
- `paths` (array): The absolute paths to the files to upload. Can be a single file or multiple files.
|
||||||
|
- Read-only: **false**
|
||||||
|
|
||||||
- **browser_close**
|
<!-- NOTE: This has been generated via update-readme.js -->
|
||||||
- Description: Close the page
|
|
||||||
- Parameters: None
|
|
||||||
|
|
||||||
|
- **browser_handle_dialog**
|
||||||
|
- Title: Handle a dialog
|
||||||
|
- Description: Handle a dialog
|
||||||
|
- Parameters:
|
||||||
|
- `accept` (boolean): Whether to accept the dialog.
|
||||||
|
- `promptText` (string, optional): The text of the prompt in case of a prompt dialog.
|
||||||
|
- Read-only: **false**
|
||||||
|
|
||||||
### Vision Mode
|
</details>
|
||||||
|
|
||||||
Vision Mode provides tools for visual-based interactions using screenshots. Here are all available tools:
|
<details>
|
||||||
|
<summary><b>Navigation</b></summary>
|
||||||
|
|
||||||
|
<!-- NOTE: This has been generated via update-readme.js -->
|
||||||
|
|
||||||
- **browser_navigate**
|
- **browser_navigate**
|
||||||
|
- Title: Navigate to a URL
|
||||||
- Description: Navigate to a URL
|
- Description: Navigate to a URL
|
||||||
- Parameters:
|
- Parameters:
|
||||||
- `url` (string): The URL to navigate to
|
- `url` (string): The URL to navigate to
|
||||||
|
- Read-only: **false**
|
||||||
|
|
||||||
- **browser_go_back**
|
<!-- NOTE: This has been generated via update-readme.js -->
|
||||||
|
|
||||||
|
- **browser_navigate_back**
|
||||||
|
- Title: Go back
|
||||||
- Description: Go back to the previous page
|
- Description: Go back to the previous page
|
||||||
- Parameters: None
|
- Parameters: None
|
||||||
|
- Read-only: **true**
|
||||||
|
|
||||||
- **browser_go_forward**
|
<!-- NOTE: This has been generated via update-readme.js -->
|
||||||
|
|
||||||
|
- **browser_navigate_forward**
|
||||||
|
- Title: Go forward
|
||||||
- Description: Go forward to the next page
|
- Description: Go forward to the next page
|
||||||
- Parameters: None
|
- Parameters: None
|
||||||
|
- Read-only: **true**
|
||||||
|
|
||||||
- **browser_screenshot**
|
</details>
|
||||||
- Description: Capture screenshot of the current page
|
|
||||||
- Parameters: None
|
|
||||||
|
|
||||||
- **browser_move_mouse**
|
<details>
|
||||||
- Description: Move mouse to specified coordinates
|
<summary><b>Resources</b></summary>
|
||||||
|
|
||||||
|
<!-- NOTE: This has been generated via update-readme.js -->
|
||||||
|
|
||||||
|
- **browser_take_screenshot**
|
||||||
|
- Title: Take a screenshot
|
||||||
|
- Description: Take a screenshot of the current page. You can't perform actions based on the screenshot, use browser_snapshot for actions.
|
||||||
- Parameters:
|
- Parameters:
|
||||||
|
- `raw` (boolean, optional): Whether to return without compression (in PNG format). Default is false, which returns a JPEG image.
|
||||||
|
- `filename` (string, optional): File name to save the screenshot to. Defaults to `page-{timestamp}.{png|jpeg}` if not specified.
|
||||||
|
- `element` (string, optional): Human-readable element description used to obtain permission to screenshot the element. If not provided, the screenshot will be taken of viewport. If element is provided, ref must be provided too.
|
||||||
|
- `ref` (string, optional): Exact target element reference from the page snapshot. If not provided, the screenshot will be taken of viewport. If ref is provided, element must be provided too.
|
||||||
|
- Read-only: **true**
|
||||||
|
|
||||||
|
<!-- NOTE: This has been generated via update-readme.js -->
|
||||||
|
|
||||||
|
- **browser_pdf_save**
|
||||||
|
- Title: Save as PDF
|
||||||
|
- Description: Save page as PDF
|
||||||
|
- Parameters:
|
||||||
|
- `filename` (string, optional): File name to save the pdf to. Defaults to `page-{timestamp}.pdf` if not specified.
|
||||||
|
- Read-only: **true**
|
||||||
|
|
||||||
|
<!-- NOTE: This has been generated via update-readme.js -->
|
||||||
|
|
||||||
|
- **browser_network_requests**
|
||||||
|
- Title: List network requests
|
||||||
|
- Description: Returns all network requests since loading the page
|
||||||
|
- Parameters: None
|
||||||
|
- Read-only: **true**
|
||||||
|
|
||||||
|
<!-- NOTE: This has been generated via update-readme.js -->
|
||||||
|
|
||||||
|
- **browser_console_messages**
|
||||||
|
- Title: Get console messages
|
||||||
|
- Description: Returns all console messages
|
||||||
|
- Parameters: None
|
||||||
|
- Read-only: **true**
|
||||||
|
|
||||||
|
</details>
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary><b>Utilities</b></summary>
|
||||||
|
|
||||||
|
<!-- NOTE: This has been generated via update-readme.js -->
|
||||||
|
|
||||||
|
- **browser_install**
|
||||||
|
- Title: Install the browser specified in the config
|
||||||
|
- Description: Install the browser specified in the config. Call this if you get an error about the browser not being installed.
|
||||||
|
- Parameters: None
|
||||||
|
- Read-only: **false**
|
||||||
|
|
||||||
|
<!-- NOTE: This has been generated via update-readme.js -->
|
||||||
|
|
||||||
|
- **browser_close**
|
||||||
|
- Title: Close browser
|
||||||
|
- Description: Close the page
|
||||||
|
- Parameters: None
|
||||||
|
- Read-only: **true**
|
||||||
|
|
||||||
|
<!-- NOTE: This has been generated via update-readme.js -->
|
||||||
|
|
||||||
|
- **browser_resize**
|
||||||
|
- Title: Resize browser window
|
||||||
|
- Description: Resize the browser window
|
||||||
|
- Parameters:
|
||||||
|
- `width` (number): Width of the browser window
|
||||||
|
- `height` (number): Height of the browser window
|
||||||
|
- Read-only: **true**
|
||||||
|
|
||||||
|
</details>
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary><b>Tabs</b></summary>
|
||||||
|
|
||||||
|
<!-- NOTE: This has been generated via update-readme.js -->
|
||||||
|
|
||||||
|
- **browser_tab_list**
|
||||||
|
- Title: List tabs
|
||||||
|
- Description: List browser tabs
|
||||||
|
- Parameters: None
|
||||||
|
- Read-only: **true**
|
||||||
|
|
||||||
|
<!-- NOTE: This has been generated via update-readme.js -->
|
||||||
|
|
||||||
|
- **browser_tab_new**
|
||||||
|
- Title: Open a new tab
|
||||||
|
- Description: Open a new tab
|
||||||
|
- Parameters:
|
||||||
|
- `url` (string, optional): The URL to navigate to in the new tab. If not provided, the new tab will be blank.
|
||||||
|
- Read-only: **true**
|
||||||
|
|
||||||
|
<!-- NOTE: This has been generated via update-readme.js -->
|
||||||
|
|
||||||
|
- **browser_tab_select**
|
||||||
|
- Title: Select a tab
|
||||||
|
- Description: Select a tab by index
|
||||||
|
- Parameters:
|
||||||
|
- `index` (number): The index of the tab to select
|
||||||
|
- Read-only: **true**
|
||||||
|
|
||||||
|
<!-- NOTE: This has been generated via update-readme.js -->
|
||||||
|
|
||||||
|
- **browser_tab_close**
|
||||||
|
- Title: Close a tab
|
||||||
|
- Description: Close a tab
|
||||||
|
- Parameters:
|
||||||
|
- `index` (number, optional): The index of the tab to close. Closes current tab if not provided.
|
||||||
|
- Read-only: **false**
|
||||||
|
|
||||||
|
</details>
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary><b>Testing</b></summary>
|
||||||
|
|
||||||
|
<!-- NOTE: This has been generated via update-readme.js -->
|
||||||
|
|
||||||
|
- **browser_generate_playwright_test**
|
||||||
|
- Title: Generate a Playwright test
|
||||||
|
- Description: Generate a Playwright test for given scenario
|
||||||
|
- Parameters:
|
||||||
|
- `name` (string): The name of the test
|
||||||
|
- `description` (string): The description of the test
|
||||||
|
- `steps` (array): The steps of the test
|
||||||
|
- Read-only: **true**
|
||||||
|
|
||||||
|
</details>
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary><b>Vision mode</b></summary>
|
||||||
|
|
||||||
|
<!-- NOTE: This has been generated via update-readme.js -->
|
||||||
|
|
||||||
|
- **browser_screen_capture**
|
||||||
|
- Title: Take a screenshot
|
||||||
|
- Description: Take a screenshot of the current page
|
||||||
|
- Parameters: None
|
||||||
|
- Read-only: **true**
|
||||||
|
|
||||||
|
<!-- NOTE: This has been generated via update-readme.js -->
|
||||||
|
|
||||||
|
- **browser_screen_move_mouse**
|
||||||
|
- Title: Move mouse
|
||||||
|
- Description: Move mouse to a given position
|
||||||
|
- Parameters:
|
||||||
|
- `element` (string): Human-readable element description used to obtain permission to interact with the element
|
||||||
- `x` (number): X coordinate
|
- `x` (number): X coordinate
|
||||||
- `y` (number): Y coordinate
|
- `y` (number): Y coordinate
|
||||||
|
- Read-only: **true**
|
||||||
|
|
||||||
- **browser_click**
|
<!-- NOTE: This has been generated via update-readme.js -->
|
||||||
- Description: Click at specified coordinates
|
|
||||||
- Parameters:
|
|
||||||
- `x` (number): X coordinate to click at
|
|
||||||
- `y` (number): Y coordinate to click at
|
|
||||||
|
|
||||||
- **browser_drag**
|
- **browser_screen_click**
|
||||||
- Description: Perform drag and drop operation
|
- Title: Click
|
||||||
|
- Description: Click left mouse button
|
||||||
- Parameters:
|
- Parameters:
|
||||||
|
- `element` (string): Human-readable element description used to obtain permission to interact with the element
|
||||||
|
- `x` (number): X coordinate
|
||||||
|
- `y` (number): Y coordinate
|
||||||
|
- Read-only: **false**
|
||||||
|
|
||||||
|
<!-- NOTE: This has been generated via update-readme.js -->
|
||||||
|
|
||||||
|
- **browser_screen_drag**
|
||||||
|
- Title: Drag mouse
|
||||||
|
- Description: Drag left mouse button
|
||||||
|
- Parameters:
|
||||||
|
- `element` (string): Human-readable element description used to obtain permission to interact with the element
|
||||||
- `startX` (number): Start X coordinate
|
- `startX` (number): Start X coordinate
|
||||||
- `startY` (number): Start Y coordinate
|
- `startY` (number): Start Y coordinate
|
||||||
- `endX` (number): End X coordinate
|
- `endX` (number): End X coordinate
|
||||||
- `endY` (number): End Y coordinate
|
- `endY` (number): End Y coordinate
|
||||||
|
- Read-only: **false**
|
||||||
|
|
||||||
- **browser_type**
|
<!-- NOTE: This has been generated via update-readme.js -->
|
||||||
- Description: Type text at specified coordinates
|
|
||||||
|
- **browser_screen_type**
|
||||||
|
- Title: Type text
|
||||||
|
- Description: Type text
|
||||||
- Parameters:
|
- Parameters:
|
||||||
- `text` (string): Text to type
|
- `text` (string): Text to type into the element
|
||||||
- `submit` (boolean): Whether to submit entered text (press Enter after)
|
- `submit` (boolean, optional): Whether to submit entered text (press Enter after)
|
||||||
|
- Read-only: **false**
|
||||||
|
|
||||||
|
<!-- NOTE: This has been generated via update-readme.js -->
|
||||||
|
|
||||||
- **browser_press_key**
|
- **browser_press_key**
|
||||||
|
- Title: Press a key
|
||||||
- Description: Press a key on the keyboard
|
- Description: Press a key on the keyboard
|
||||||
- Parameters:
|
- Parameters:
|
||||||
- `key` (string): Name of the key to press or a character to generate, such as `ArrowLeft` or `a`
|
- `key` (string): Name of the key to press or a character to generate, such as `ArrowLeft` or `a`
|
||||||
|
- Read-only: **false**
|
||||||
|
|
||||||
- **browser_choose_file**
|
<!-- NOTE: This has been generated via update-readme.js -->
|
||||||
- Description: Choose one or multiple files to upload
|
|
||||||
|
- **browser_wait_for**
|
||||||
|
- Title: Wait for
|
||||||
|
- Description: Wait for text to appear or disappear or a specified time to pass
|
||||||
|
- Parameters:
|
||||||
|
- `time` (number, optional): The time to wait in seconds
|
||||||
|
- `text` (string, optional): The text to wait for
|
||||||
|
- `textGone` (string, optional): The text to wait for to disappear
|
||||||
|
- Read-only: **true**
|
||||||
|
|
||||||
|
<!-- NOTE: This has been generated via update-readme.js -->
|
||||||
|
|
||||||
|
- **browser_file_upload**
|
||||||
|
- Title: Upload files
|
||||||
|
- Description: Upload one or multiple files
|
||||||
- Parameters:
|
- Parameters:
|
||||||
- `paths` (array): The absolute paths to the files to upload. Can be a single file or multiple files.
|
- `paths` (array): The absolute paths to the files to upload. Can be a single file or multiple files.
|
||||||
|
- Read-only: **false**
|
||||||
|
|
||||||
- **browser_save_as_pdf**
|
<!-- NOTE: This has been generated via update-readme.js -->
|
||||||
- Description: Save page as PDF
|
|
||||||
- Parameters: None
|
|
||||||
|
|
||||||
- **browser_wait**
|
- **browser_handle_dialog**
|
||||||
- Description: Wait for a specified time in seconds
|
- Title: Handle a dialog
|
||||||
|
- Description: Handle a dialog
|
||||||
- Parameters:
|
- Parameters:
|
||||||
- `time` (number): The time to wait in seconds (capped at 10 seconds)
|
- `accept` (boolean): Whether to accept the dialog.
|
||||||
|
- `promptText` (string, optional): The text of the prompt in case of a prompt dialog.
|
||||||
|
- Read-only: **false**
|
||||||
|
|
||||||
- **browser_close**
|
</details>
|
||||||
- Description: Close the page
|
|
||||||
- Parameters: None
|
|
||||||
|
<!--- End of tools generated section -->
|
||||||
|
|||||||
2
cli.js
2
cli.js
@@ -15,4 +15,4 @@
|
|||||||
* limitations under the License.
|
* limitations under the License.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
require('./lib/program');
|
import './lib/program.js';
|
||||||
|
|||||||
128
config.d.ts
vendored
Normal file
128
config.d.ts
vendored
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 type * as playwright from 'playwright';
|
||||||
|
|
||||||
|
export type ToolCapability = 'core' | 'tabs' | 'pdf' | 'history' | 'wait' | 'files' | 'install' | 'testing';
|
||||||
|
|
||||||
|
export type Config = {
|
||||||
|
/**
|
||||||
|
* The browser to use.
|
||||||
|
*/
|
||||||
|
browser?: {
|
||||||
|
/**
|
||||||
|
* Use browser agent (experimental).
|
||||||
|
*/
|
||||||
|
browserAgent?: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The type of browser to use.
|
||||||
|
*/
|
||||||
|
browserName?: 'chromium' | 'firefox' | 'webkit';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Keep the browser profile in memory, do not save it to disk.
|
||||||
|
*/
|
||||||
|
isolated?: boolean;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Path to a user data directory for browser profile persistence.
|
||||||
|
* Temporary directory is created by default.
|
||||||
|
*/
|
||||||
|
userDataDir?: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Launch options passed to
|
||||||
|
* @see https://playwright.dev/docs/api/class-browsertype#browser-type-launch-persistent-context
|
||||||
|
*
|
||||||
|
* This is useful for settings options like `channel`, `headless`, `executablePath`, etc.
|
||||||
|
*/
|
||||||
|
launchOptions?: playwright.LaunchOptions;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Context options for the browser context.
|
||||||
|
*
|
||||||
|
* This is useful for settings options like `viewport`.
|
||||||
|
*/
|
||||||
|
contextOptions?: playwright.BrowserContextOptions;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Chrome DevTools Protocol endpoint to connect to an existing browser instance in case of Chromium family browsers.
|
||||||
|
*/
|
||||||
|
cdpEndpoint?: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remote endpoint to connect to an existing Playwright server.
|
||||||
|
*/
|
||||||
|
remoteEndpoint?: string;
|
||||||
|
},
|
||||||
|
|
||||||
|
server?: {
|
||||||
|
/**
|
||||||
|
* The port to listen on for SSE or MCP transport.
|
||||||
|
*/
|
||||||
|
port?: number;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The host to bind the server to. Default is localhost. Use 0.0.0.0 to bind to all interfaces.
|
||||||
|
*/
|
||||||
|
host?: string;
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* List of enabled tool capabilities. Possible values:
|
||||||
|
* - 'core': Core browser automation features.
|
||||||
|
* - 'tabs': Tab management features.
|
||||||
|
* - 'pdf': PDF generation and manipulation.
|
||||||
|
* - 'history': Browser history access.
|
||||||
|
* - 'wait': Wait and timing utilities.
|
||||||
|
* - 'files': File upload/download support.
|
||||||
|
* - 'install': Browser installation utilities.
|
||||||
|
*/
|
||||||
|
capabilities?: ToolCapability[];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Run server that uses screenshots (Aria snapshots are used by default).
|
||||||
|
*/
|
||||||
|
vision?: boolean;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether to save the Playwright trace of the session into the output directory.
|
||||||
|
*/
|
||||||
|
saveTrace?: boolean;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The directory to save output files.
|
||||||
|
*/
|
||||||
|
outputDir?: string;
|
||||||
|
|
||||||
|
network?: {
|
||||||
|
/**
|
||||||
|
* List of origins to allow the browser to request. Default is to allow all. Origins matching both `allowedOrigins` and `blockedOrigins` will be blocked.
|
||||||
|
*/
|
||||||
|
allowedOrigins?: string[];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* List of origins to block the browser to request. Origins matching both `allowedOrigins` and `blockedOrigins` will be blocked.
|
||||||
|
*/
|
||||||
|
blockedOrigins?: string[];
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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' | 'auto';
|
||||||
|
};
|
||||||
@@ -33,6 +33,8 @@ const plugins = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const baseRules = {
|
export const baseRules = {
|
||||||
|
"import/extensions": ["error", "ignorePackages", {ts: "always"}],
|
||||||
|
"@typescript-eslint/no-floating-promises": "error",
|
||||||
"@typescript-eslint/no-unused-vars": [
|
"@typescript-eslint/no-unused-vars": [
|
||||||
2,
|
2,
|
||||||
{ args: "none", caughtErrors: "none" },
|
{ args: "none", caughtErrors: "none" },
|
||||||
@@ -178,12 +180,16 @@ export const baseRules = {
|
|||||||
|
|
||||||
// react
|
// react
|
||||||
"react/react-in-jsx-scope": 0,
|
"react/react-in-jsx-scope": 0,
|
||||||
|
"no-console": 2,
|
||||||
};
|
};
|
||||||
|
|
||||||
const languageOptions = {
|
const languageOptions = {
|
||||||
parser: tsParser,
|
parser: tsParser,
|
||||||
ecmaVersion: 9,
|
ecmaVersion: 9,
|
||||||
sourceType: "module",
|
sourceType: "module",
|
||||||
|
parserOptions: {
|
||||||
|
project: path.join(fileURLToPath(import.meta.url), "..", "tsconfig.all.json"),
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
export default [
|
export default [
|
||||||
|
|||||||
10
examples/generate-test.md
Normal file
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.
|
||||||
26
index.d.ts
vendored
26
index.d.ts
vendored
@@ -15,26 +15,14 @@
|
|||||||
* limitations under the License.
|
* limitations under the License.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import type { LaunchOptions } from 'playwright';
|
|
||||||
import type { Server } from '@modelcontextprotocol/sdk/server/index.js';
|
import type { Server } from '@modelcontextprotocol/sdk/server/index.js';
|
||||||
|
import type { Config } from './config.js';
|
||||||
|
import type { BrowserContext } from 'playwright';
|
||||||
|
|
||||||
type Options = {
|
export type Connection = {
|
||||||
/**
|
server: Server;
|
||||||
* Path to the user data directory.
|
close(): Promise<void>;
|
||||||
*/
|
|
||||||
userDataDir?: string;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Launch options for the browser.
|
|
||||||
*/
|
|
||||||
launchOptions?: LaunchOptions;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Use screenshots instead of snapshots. Less accurate, reliable and overall
|
|
||||||
* slower, but contains visual representation of the page.
|
|
||||||
* @default false
|
|
||||||
*/
|
|
||||||
vision?: boolean;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export function createServer(options?: Options): Server;
|
export declare function createConnection(config?: Config, contextGetter?: () => Promise<BrowserContext>): Promise<Connection>;
|
||||||
|
export {};
|
||||||
|
|||||||
4
index.js
4
index.js
@@ -15,5 +15,5 @@
|
|||||||
* limitations under the License.
|
* limitations under the License.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
const { createServer } = require('./lib/index');
|
import { createConnection } from './lib/index.js';
|
||||||
module.exports = { createServer };
|
export { createConnection };
|
||||||
|
|||||||
447
package-lock.json
generated
447
package-lock.json
generated
@@ -1,18 +1,20 @@
|
|||||||
{
|
{
|
||||||
"name": "@playwright/mcp",
|
"name": "@playwright/mcp",
|
||||||
"version": "0.0.9",
|
"version": "0.0.30",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "@playwright/mcp",
|
"name": "@playwright/mcp",
|
||||||
"version": "0.0.9",
|
"version": "0.0.30",
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@modelcontextprotocol/sdk": "^1.6.1",
|
"@modelcontextprotocol/sdk": "^1.11.0",
|
||||||
"commander": "^13.1.0",
|
"commander": "^13.1.0",
|
||||||
"playwright": "^1.52.0-alpha-1743163434000",
|
"debug": "^4.4.1",
|
||||||
"yaml": "^2.7.1",
|
"mime": "^4.0.7",
|
||||||
|
"playwright": "1.54.1",
|
||||||
|
"ws": "^8.18.1",
|
||||||
"zod-to-json-schema": "^3.24.4"
|
"zod-to-json-schema": "^3.24.4"
|
||||||
},
|
},
|
||||||
"bin": {
|
"bin": {
|
||||||
@@ -21,9 +23,12 @@
|
|||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/eslintrc": "^3.2.0",
|
"@eslint/eslintrc": "^3.2.0",
|
||||||
"@eslint/js": "^9.19.0",
|
"@eslint/js": "^9.19.0",
|
||||||
"@playwright/test": "^1.52.0-alpha-1743163434000",
|
"@playwright/test": "1.54.1",
|
||||||
"@stylistic/eslint-plugin": "^3.0.1",
|
"@stylistic/eslint-plugin": "^3.0.1",
|
||||||
|
"@types/chrome": "^0.0.315",
|
||||||
|
"@types/debug": "^4.1.12",
|
||||||
"@types/node": "^22.13.10",
|
"@types/node": "^22.13.10",
|
||||||
|
"@types/ws": "^8.18.1",
|
||||||
"@typescript-eslint/eslint-plugin": "^8.26.1",
|
"@typescript-eslint/eslint-plugin": "^8.26.1",
|
||||||
"@typescript-eslint/parser": "^8.26.1",
|
"@typescript-eslint/parser": "^8.26.1",
|
||||||
"@typescript-eslint/utils": "^8.26.1",
|
"@typescript-eslint/utils": "^8.26.1",
|
||||||
@@ -228,17 +233,18 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@modelcontextprotocol/sdk": {
|
"node_modules/@modelcontextprotocol/sdk": {
|
||||||
"version": "1.7.0",
|
"version": "1.11.0",
|
||||||
"resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.7.0.tgz",
|
"resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.11.0.tgz",
|
||||||
"integrity": "sha512-IYPe/FLpvF3IZrd/f5p5ffmWhMc3aEMuM2wGJASDqC2Ge7qatVCdbfPx3n/5xFeb19xN0j/911M2AaFuircsWA==",
|
"integrity": "sha512-k/1pb70eD638anoi0e8wUGAlbMJXyvdV4p62Ko+EZ7eBe1xMx8Uhak1R5DgfoofsK5IBBnRwsYGTaLZl+6/+RQ==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"content-type": "^1.0.5",
|
"content-type": "^1.0.5",
|
||||||
"cors": "^2.8.5",
|
"cors": "^2.8.5",
|
||||||
|
"cross-spawn": "^7.0.3",
|
||||||
"eventsource": "^3.0.2",
|
"eventsource": "^3.0.2",
|
||||||
"express": "^5.0.1",
|
"express": "^5.0.1",
|
||||||
"express-rate-limit": "^7.5.0",
|
"express-rate-limit": "^7.5.0",
|
||||||
"pkce-challenge": "^4.1.0",
|
"pkce-challenge": "^5.0.0",
|
||||||
"raw-body": "^3.0.0",
|
"raw-body": "^3.0.0",
|
||||||
"zod": "^3.23.8",
|
"zod": "^3.23.8",
|
||||||
"zod-to-json-schema": "^3.24.1"
|
"zod-to-json-schema": "^3.24.1"
|
||||||
@@ -286,13 +292,13 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@playwright/test": {
|
"node_modules/@playwright/test": {
|
||||||
"version": "1.52.0-alpha-1743163434000",
|
"version": "1.54.1",
|
||||||
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.52.0-alpha-1743163434000.tgz",
|
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.54.1.tgz",
|
||||||
"integrity": "sha512-4uBgNlJ6hgPtB8DrwQsgoKuVoe7j+nPqudna7CLXWCmmT3LYPMD5aOjGoBkszr+R9NejtKashq/bOi/ny9hsIA==",
|
"integrity": "sha512-FS8hQ12acieG2dYSksmLOF7BNxnVf2afRJdCuM1eMSxj6QTSE6G4InGF7oApGgDb65MX7AwMVlIkpru0yZA4Xw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"playwright": "1.52.0-alpha-1743163434000"
|
"playwright": "1.54.1"
|
||||||
},
|
},
|
||||||
"bin": {
|
"bin": {
|
||||||
"playwright": "cli.js"
|
"playwright": "cli.js"
|
||||||
@@ -354,6 +360,27 @@
|
|||||||
"url": "https://github.com/sponsors/jonschlinkert"
|
"url": "https://github.com/sponsors/jonschlinkert"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/chrome": {
|
||||||
|
"version": "0.0.315",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/chrome/-/chrome-0.0.315.tgz",
|
||||||
|
"integrity": "sha512-Oy1dYWkr6BCmgwBtOngLByCHstQ3whltZg7/7lubgIZEYvKobDneqplgc6LKERNRBwckFviV4UU5AZZNUFrJ4A==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/filesystem": "*",
|
||||||
|
"@types/har-format": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@types/debug": {
|
||||||
|
"version": "4.1.12",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.12.tgz",
|
||||||
|
"integrity": "sha512-vIChWdVG3LG1SMxEvI/AK+FWJthlrqlTu7fbrlywTkkaONwk/UAGaULXRlf8vkzFBLVm0zkMdCquhL5aOjhXPQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/ms": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@types/estree": {
|
"node_modules/@types/estree": {
|
||||||
"version": "1.0.6",
|
"version": "1.0.6",
|
||||||
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.6.tgz",
|
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.6.tgz",
|
||||||
@@ -361,6 +388,30 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/filesystem": {
|
||||||
|
"version": "0.0.36",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/filesystem/-/filesystem-0.0.36.tgz",
|
||||||
|
"integrity": "sha512-vPDXOZuannb9FZdxgHnqSwAG/jvdGM8Wq+6N4D/d80z+D4HWH+bItqsZaVRQykAn6WEVeEkLm2oQigyHtgb0RA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/filewriter": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@types/filewriter": {
|
||||||
|
"version": "0.0.33",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/filewriter/-/filewriter-0.0.33.tgz",
|
||||||
|
"integrity": "sha512-xFU8ZXTw4gd358lb2jw25nxY9QAgqn2+bKKjKOYfNCzN4DKCFetK7sPtrlpg66Ywe3vWY9FNxprZawAh9wfJ3g==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/@types/har-format": {
|
||||||
|
"version": "1.2.16",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/har-format/-/har-format-1.2.16.tgz",
|
||||||
|
"integrity": "sha512-fluxdy7ryD3MV6h8pTfTYpy/xQzCFC7m89nOH9y94cNqJ1mDIDPut7MnRHI3F6qRmh/cT2fUjG1MLdCNb4hE9A==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/@types/json-schema": {
|
"node_modules/@types/json-schema": {
|
||||||
"version": "7.0.15",
|
"version": "7.0.15",
|
||||||
"resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz",
|
"resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz",
|
||||||
@@ -375,14 +426,33 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/ms": {
|
||||||
|
"version": "2.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz",
|
||||||
|
"integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/@types/node": {
|
"node_modules/@types/node": {
|
||||||
"version": "22.13.10",
|
"version": "22.13.10",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.13.10.tgz",
|
||||||
|
"integrity": "sha512-I6LPUvlRH+O6VRUqYOcMudhaIdUVWfsjnZavnsraHvpBwaEyMN29ry+0UVJhImYL16xsscu0aske3yA+uPOWfw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"undici-types": "~6.20.0"
|
"undici-types": "~6.20.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/ws": {
|
||||||
|
"version": "8.18.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz",
|
||||||
|
"integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/node": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@typescript-eslint/eslint-plugin": {
|
"node_modules/@typescript-eslint/eslint-plugin": {
|
||||||
"version": "8.27.0",
|
"version": "8.27.0",
|
||||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.27.0.tgz",
|
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.27.0.tgz",
|
||||||
@@ -832,16 +902,16 @@
|
|||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/body-parser": {
|
"node_modules/body-parser": {
|
||||||
"version": "2.1.0",
|
"version": "2.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.0.tgz",
|
||||||
"integrity": "sha512-/hPxh61E+ll0Ujp24Ilm64cykicul1ypfwjVttduAiEdtnJFvLePSrIPk+HMImtNv5270wOGCb1Tns2rybMkoQ==",
|
"integrity": "sha512-02qvAaxv8tp7fBa/mw1ga98OGm+eCbqzJOKoRt70sLmfEEi+jyBYVTDGfCL/k06/4EMk/z01gCe7HoCH/f2LTg==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"bytes": "^3.1.2",
|
"bytes": "^3.1.2",
|
||||||
"content-type": "^1.0.5",
|
"content-type": "^1.0.5",
|
||||||
"debug": "^4.4.0",
|
"debug": "^4.4.0",
|
||||||
"http-errors": "^2.0.0",
|
"http-errors": "^2.0.0",
|
||||||
"iconv-lite": "^0.5.2",
|
"iconv-lite": "^0.6.3",
|
||||||
"on-finished": "^2.4.1",
|
"on-finished": "^2.4.1",
|
||||||
"qs": "^6.14.0",
|
"qs": "^6.14.0",
|
||||||
"raw-body": "^3.0.0",
|
"raw-body": "^3.0.0",
|
||||||
@@ -851,44 +921,6 @@
|
|||||||
"node": ">=18"
|
"node": ">=18"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/body-parser/node_modules/debug": {
|
|
||||||
"version": "4.4.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.0.tgz",
|
|
||||||
"integrity": "sha512-6WTZ/IxCY/T6BALoZHaE4ctp9xm+Z5kY/pzYaCHRFeyVhojxlrm+46y68HA6hr0TcwEssoxNiDEUJQjfPZ/RYA==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"ms": "^2.1.3"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=6.0"
|
|
||||||
},
|
|
||||||
"peerDependenciesMeta": {
|
|
||||||
"supports-color": {
|
|
||||||
"optional": true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/body-parser/node_modules/ms": {
|
|
||||||
"version": "2.1.3",
|
|
||||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
|
||||||
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
|
|
||||||
"license": "MIT"
|
|
||||||
},
|
|
||||||
"node_modules/body-parser/node_modules/qs": {
|
|
||||||
"version": "6.14.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/qs/-/qs-6.14.0.tgz",
|
|
||||||
"integrity": "sha512-YWWTjgABSKcvs/nWBi9PycY/JiPJqOD4JA6o9Sej2AtvSGarXxKC3OQSk4pAarbdQlKAh5D4FCQkJNkW+GAn3w==",
|
|
||||||
"license": "BSD-3-Clause",
|
|
||||||
"dependencies": {
|
|
||||||
"side-channel": "^1.1.0"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=0.6"
|
|
||||||
},
|
|
||||||
"funding": {
|
|
||||||
"url": "https://github.com/sponsors/ljharb"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/brace-expansion": {
|
"node_modules/brace-expansion": {
|
||||||
"version": "1.1.11",
|
"version": "1.1.11",
|
||||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz",
|
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz",
|
||||||
@@ -1019,6 +1051,8 @@
|
|||||||
},
|
},
|
||||||
"node_modules/commander": {
|
"node_modules/commander": {
|
||||||
"version": "13.1.0",
|
"version": "13.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/commander/-/commander-13.1.0.tgz",
|
||||||
|
"integrity": "sha512-/rFeCpNJQbhSZjGVwO9RFV3xPqbnERS8MmIQzCtD/zl6gpJuV/bMLuN92oG3F7d8oDEHHRrujSXNUr8fpjntKw==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=18"
|
"node": ">=18"
|
||||||
@@ -1087,7 +1121,6 @@
|
|||||||
"version": "7.0.6",
|
"version": "7.0.6",
|
||||||
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
|
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
|
||||||
"integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==",
|
"integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"path-key": "^3.1.0",
|
"path-key": "^3.1.0",
|
||||||
@@ -1153,12 +1186,12 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/debug": {
|
"node_modules/debug": {
|
||||||
"version": "4.3.6",
|
"version": "4.4.1",
|
||||||
"resolved": "https://registry.npmjs.org/debug/-/debug-4.3.6.tgz",
|
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.1.tgz",
|
||||||
"integrity": "sha512-O/09Bd4Z1fBrU4VzkhFqVgpPzaGbw6Sm9FEkBT1A/YBXQFGuuSxa1dN2nxgxS34JmKXqYx8CZAwEVoJFImUXIg==",
|
"integrity": "sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"ms": "2.1.2"
|
"ms": "^2.1.3"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=6.0"
|
"node": ">=6.0"
|
||||||
@@ -1221,16 +1254,6 @@
|
|||||||
"node": ">= 0.8"
|
"node": ">= 0.8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/destroy": {
|
|
||||||
"version": "1.2.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz",
|
|
||||||
"integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==",
|
|
||||||
"license": "MIT",
|
|
||||||
"engines": {
|
|
||||||
"node": ">= 0.8",
|
|
||||||
"npm": "1.2.8000 || >= 1.4.16"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/doctrine": {
|
"node_modules/doctrine": {
|
||||||
"version": "2.1.0",
|
"version": "2.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/doctrine/-/doctrine-2.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/doctrine/-/doctrine-2.1.0.tgz",
|
||||||
@@ -1766,46 +1789,45 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/express": {
|
"node_modules/express": {
|
||||||
"version": "5.0.1",
|
"version": "5.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/express/-/express-5.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/express/-/express-5.1.0.tgz",
|
||||||
"integrity": "sha512-ORF7g6qGnD+YtUG9yx4DFoqCShNMmUKiXuT5oWMHiOvt/4WFbHC6yCwQMTSBMno7AqntNCAzzcnnjowRkTL9eQ==",
|
"integrity": "sha512-DT9ck5YIRU+8GYzzU5kT3eHGA5iL+1Zd0EutOmTE9Dtk+Tvuzd23VBU+ec7HPNSTxXYO55gPV/hq4pSBJDjFpA==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"accepts": "^2.0.0",
|
"accepts": "^2.0.0",
|
||||||
"body-parser": "^2.0.1",
|
"body-parser": "^2.2.0",
|
||||||
"content-disposition": "^1.0.0",
|
"content-disposition": "^1.0.0",
|
||||||
"content-type": "~1.0.4",
|
"content-type": "^1.0.5",
|
||||||
"cookie": "0.7.1",
|
"cookie": "^0.7.1",
|
||||||
"cookie-signature": "^1.2.1",
|
"cookie-signature": "^1.2.1",
|
||||||
"debug": "4.3.6",
|
"debug": "^4.4.0",
|
||||||
"depd": "2.0.0",
|
"encodeurl": "^2.0.0",
|
||||||
"encodeurl": "~2.0.0",
|
"escape-html": "^1.0.3",
|
||||||
"escape-html": "~1.0.3",
|
"etag": "^1.8.1",
|
||||||
"etag": "~1.8.1",
|
"finalhandler": "^2.1.0",
|
||||||
"finalhandler": "^2.0.0",
|
"fresh": "^2.0.0",
|
||||||
"fresh": "2.0.0",
|
"http-errors": "^2.0.0",
|
||||||
"http-errors": "2.0.0",
|
|
||||||
"merge-descriptors": "^2.0.0",
|
"merge-descriptors": "^2.0.0",
|
||||||
"methods": "~1.1.2",
|
|
||||||
"mime-types": "^3.0.0",
|
"mime-types": "^3.0.0",
|
||||||
"on-finished": "2.4.1",
|
"on-finished": "^2.4.1",
|
||||||
"once": "1.4.0",
|
"once": "^1.4.0",
|
||||||
"parseurl": "~1.3.3",
|
"parseurl": "^1.3.3",
|
||||||
"proxy-addr": "~2.0.7",
|
"proxy-addr": "^2.0.7",
|
||||||
"qs": "6.13.0",
|
"qs": "^6.14.0",
|
||||||
"range-parser": "~1.2.1",
|
"range-parser": "^1.2.1",
|
||||||
"router": "^2.0.0",
|
"router": "^2.2.0",
|
||||||
"safe-buffer": "5.2.1",
|
|
||||||
"send": "^1.1.0",
|
"send": "^1.1.0",
|
||||||
"serve-static": "^2.1.0",
|
"serve-static": "^2.2.0",
|
||||||
"setprototypeof": "1.2.0",
|
"statuses": "^2.0.1",
|
||||||
"statuses": "2.0.1",
|
"type-is": "^2.0.1",
|
||||||
"type-is": "^2.0.0",
|
"vary": "^1.1.2"
|
||||||
"utils-merge": "1.0.1",
|
|
||||||
"vary": "~1.1.2"
|
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">= 18"
|
"node": ">= 18"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/express"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/express-rate-limit": {
|
"node_modules/express-rate-limit": {
|
||||||
@@ -1927,29 +1949,6 @@
|
|||||||
"node": ">= 0.8"
|
"node": ">= 0.8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/finalhandler/node_modules/debug": {
|
|
||||||
"version": "4.4.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.0.tgz",
|
|
||||||
"integrity": "sha512-6WTZ/IxCY/T6BALoZHaE4ctp9xm+Z5kY/pzYaCHRFeyVhojxlrm+46y68HA6hr0TcwEssoxNiDEUJQjfPZ/RYA==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"ms": "^2.1.3"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=6.0"
|
|
||||||
},
|
|
||||||
"peerDependenciesMeta": {
|
|
||||||
"supports-color": {
|
|
||||||
"optional": true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/finalhandler/node_modules/ms": {
|
|
||||||
"version": "2.1.3",
|
|
||||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
|
||||||
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
|
|
||||||
"license": "MIT"
|
|
||||||
},
|
|
||||||
"node_modules/find-root": {
|
"node_modules/find-root": {
|
||||||
"version": "1.1.0",
|
"version": "1.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/find-root/-/find-root-1.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/find-root/-/find-root-1.1.0.tgz",
|
||||||
@@ -2309,12 +2308,12 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/iconv-lite": {
|
"node_modules/iconv-lite": {
|
||||||
"version": "0.5.2",
|
"version": "0.6.3",
|
||||||
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.5.2.tgz",
|
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz",
|
||||||
"integrity": "sha512-kERHXvpSaB4aU3eANwidg79K8FlrN77m8G9V+0vOR3HYaRifrlwMEpT7ZBJqLSEIHnEgJTHcWK82wwLwwKwtag==",
|
"integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"safer-buffer": ">= 2.1.2 < 3"
|
"safer-buffer": ">= 2.1.2 < 3.0.0"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=0.10.0"
|
"node": ">=0.10.0"
|
||||||
@@ -2782,7 +2781,6 @@
|
|||||||
"version": "2.0.0",
|
"version": "2.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz",
|
||||||
"integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==",
|
"integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==",
|
||||||
"dev": true,
|
|
||||||
"license": "ISC"
|
"license": "ISC"
|
||||||
},
|
},
|
||||||
"node_modules/js-yaml": {
|
"node_modules/js-yaml": {
|
||||||
@@ -2926,15 +2924,6 @@
|
|||||||
"node": ">= 8"
|
"node": ">= 8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/methods": {
|
|
||||||
"version": "1.1.2",
|
|
||||||
"resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz",
|
|
||||||
"integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==",
|
|
||||||
"license": "MIT",
|
|
||||||
"engines": {
|
|
||||||
"node": ">= 0.6"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/metric-lcs": {
|
"node_modules/metric-lcs": {
|
||||||
"version": "0.1.2",
|
"version": "0.1.2",
|
||||||
"resolved": "https://registry.npmjs.org/metric-lcs/-/metric-lcs-0.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/metric-lcs/-/metric-lcs-0.1.2.tgz",
|
||||||
@@ -2956,6 +2945,21 @@
|
|||||||
"node": ">=8.6"
|
"node": ">=8.6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/mime": {
|
||||||
|
"version": "4.0.7",
|
||||||
|
"resolved": "https://registry.npmjs.org/mime/-/mime-4.0.7.tgz",
|
||||||
|
"integrity": "sha512-2OfDPL+e03E0LrXaGYOtTFIYhiuzep94NSsuhrNULq+stylcJedcHdzHtz0atMUuGwJfFYs0YL5xeC/Ca2x0eQ==",
|
||||||
|
"funding": [
|
||||||
|
"https://github.com/sponsors/broofa"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"bin": {
|
||||||
|
"mime": "bin/cli.js"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=16"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/mime-db": {
|
"node_modules/mime-db": {
|
||||||
"version": "1.54.0",
|
"version": "1.54.0",
|
||||||
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz",
|
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz",
|
||||||
@@ -2966,12 +2970,12 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/mime-types": {
|
"node_modules/mime-types": {
|
||||||
"version": "3.0.0",
|
"version": "3.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.1.tgz",
|
||||||
"integrity": "sha512-XqoSHeCGjVClAmoGFG3lVFqQFRIrTVw2OH3axRqAcfaw+gHWIfnASS92AV+Rl/mk0MupgZTRHQOjxY6YVnzK5w==",
|
"integrity": "sha512-xRc4oEhT6eaBpU1XF7AjpOFD+xQmXNB5OVKwp4tqCuBpHLS/ZbBDrc07mYTDqVMg6PfxUjjNp85O6Cd2Z/5HWA==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"mime-db": "^1.53.0"
|
"mime-db": "^1.54.0"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">= 0.6"
|
"node": ">= 0.6"
|
||||||
@@ -3001,9 +3005,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/ms": {
|
"node_modules/ms": {
|
||||||
"version": "2.1.2",
|
"version": "2.1.3",
|
||||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
||||||
"integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==",
|
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/natural-compare": {
|
"node_modules/natural-compare": {
|
||||||
@@ -3252,7 +3256,6 @@
|
|||||||
"version": "3.1.1",
|
"version": "3.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz",
|
||||||
"integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==",
|
"integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=8"
|
"node": ">=8"
|
||||||
@@ -3288,21 +3291,21 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/pkce-challenge": {
|
"node_modules/pkce-challenge": {
|
||||||
"version": "4.1.0",
|
"version": "5.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/pkce-challenge/-/pkce-challenge-4.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/pkce-challenge/-/pkce-challenge-5.0.0.tgz",
|
||||||
"integrity": "sha512-ZBmhE1C9LcPoH9XZSdwiPtbPHZROwAnMy+kIFQVrnMCxY4Cudlz3gBOpzilgc0jOgRaiT3sIWfpMomW2ar2orQ==",
|
"integrity": "sha512-ueGLflrrnvwB3xuo/uGob5pd5FN7l0MsLf0Z87o/UQmRtwjvfylfc9MurIxRAWywCYTgrvpXBcqjV4OfCYGCIQ==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=16.20.0"
|
"node": ">=16.20.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/playwright": {
|
"node_modules/playwright": {
|
||||||
"version": "1.52.0-alpha-1743163434000",
|
"version": "1.54.1",
|
||||||
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.52.0-alpha-1743163434000.tgz",
|
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.54.1.tgz",
|
||||||
"integrity": "sha512-4uYv49ekPjolydfFfTfFQ2z4URF9UZMVUXLy7aXam/tPxEQ5O7+jQC+yzrDMGmhcj5QkMnxjlyk7N2V9a0QLdQ==",
|
"integrity": "sha512-peWpSwIBmSLi6aW2auvrUtf2DqY16YYcCMO8rTVx486jKmDTJg7UAhyrraP98GB8BoPURZP8+nxO7TSd4cPr5g==",
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"playwright-core": "1.52.0-alpha-1743163434000"
|
"playwright-core": "1.54.1"
|
||||||
},
|
},
|
||||||
"bin": {
|
"bin": {
|
||||||
"playwright": "cli.js"
|
"playwright": "cli.js"
|
||||||
@@ -3315,9 +3318,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/playwright-core": {
|
"node_modules/playwright-core": {
|
||||||
"version": "1.52.0-alpha-1743163434000",
|
"version": "1.54.1",
|
||||||
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.52.0-alpha-1743163434000.tgz",
|
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.54.1.tgz",
|
||||||
"integrity": "sha512-Tn4u3Ywwjkh847/bYWlXIrNxv5DRJRDgtb+VYMXHvNCKkrxL6yfZ1ApIAYD7IAkkKH/KLTXszGWl3a/Z/KDfQA==",
|
"integrity": "sha512-Nbjs2zjj0htNhzgiy5wu+3w09YetDx5pkrpI/kZotDlDUaYk0HVA5xrBVPdow4SAUIlhgKcJeJg4GRKW6xHusA==",
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"bin": {
|
"bin": {
|
||||||
"playwright-core": "cli.js"
|
"playwright-core": "cli.js"
|
||||||
@@ -3370,12 +3373,12 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/qs": {
|
"node_modules/qs": {
|
||||||
"version": "6.13.0",
|
"version": "6.14.0",
|
||||||
"resolved": "https://registry.npmjs.org/qs/-/qs-6.13.0.tgz",
|
"resolved": "https://registry.npmjs.org/qs/-/qs-6.14.0.tgz",
|
||||||
"integrity": "sha512-+38qI9SOr8tfZ4QmJNplMUxqjbe7LKvvZgWdExBOmd+egZTtjLB67Gu0HRX3u/XOq7UU2Nx6nsjvS16Z9uwfpg==",
|
"integrity": "sha512-YWWTjgABSKcvs/nWBi9PycY/JiPJqOD4JA6o9Sej2AtvSGarXxKC3OQSk4pAarbdQlKAh5D4FCQkJNkW+GAn3w==",
|
||||||
"license": "BSD-3-Clause",
|
"license": "BSD-3-Clause",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"side-channel": "^1.0.6"
|
"side-channel": "^1.1.0"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=0.6"
|
"node": ">=0.6"
|
||||||
@@ -3429,18 +3432,6 @@
|
|||||||
"node": ">= 0.8"
|
"node": ">= 0.8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/raw-body/node_modules/iconv-lite": {
|
|
||||||
"version": "0.6.3",
|
|
||||||
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz",
|
|
||||||
"integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"safer-buffer": ">= 2.1.2 < 3.0.0"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=0.10.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/reflect.getprototypeof": {
|
"node_modules/reflect.getprototypeof": {
|
||||||
"version": "1.0.10",
|
"version": "1.0.10",
|
||||||
"resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz",
|
"resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz",
|
||||||
@@ -3528,11 +3519,13 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/router": {
|
"node_modules/router": {
|
||||||
"version": "2.1.0",
|
"version": "2.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/router/-/router-2.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz",
|
||||||
"integrity": "sha512-/m/NSLxeYEgWNtyC+WtNHCF7jbGxOibVWKnn+1Psff4dJGOfoXP+MuC/f2CwSmyiHdOIzYnYFp4W6GxWfekaLA==",
|
"integrity": "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"debug": "^4.4.0",
|
||||||
|
"depd": "^2.0.0",
|
||||||
"is-promise": "^4.0.0",
|
"is-promise": "^4.0.0",
|
||||||
"parseurl": "^1.3.3",
|
"parseurl": "^1.3.3",
|
||||||
"path-to-regexp": "^8.0.0"
|
"path-to-regexp": "^8.0.0"
|
||||||
@@ -3660,19 +3653,18 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/send": {
|
"node_modules/send": {
|
||||||
"version": "1.1.0",
|
"version": "1.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/send/-/send-1.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/send/-/send-1.2.0.tgz",
|
||||||
"integrity": "sha512-v67WcEouB5GxbTWL/4NeToqcZiAWEq90N888fczVArY8A79J0L4FD7vj5hm3eUMua5EpoQ59wa/oovY6TLvRUA==",
|
"integrity": "sha512-uaW0WwXKpL9blXE2o0bRhoL2EGXIrZxQ2ZQ4mgcfoBxdFmQold+qWsD2jLrfZ0trjKL6vOw0j//eAwcALFjKSw==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"debug": "^4.3.5",
|
"debug": "^4.3.5",
|
||||||
"destroy": "^1.2.0",
|
|
||||||
"encodeurl": "^2.0.0",
|
"encodeurl": "^2.0.0",
|
||||||
"escape-html": "^1.0.3",
|
"escape-html": "^1.0.3",
|
||||||
"etag": "^1.8.1",
|
"etag": "^1.8.1",
|
||||||
"fresh": "^0.5.2",
|
"fresh": "^2.0.0",
|
||||||
"http-errors": "^2.0.0",
|
"http-errors": "^2.0.0",
|
||||||
"mime-types": "^2.1.35",
|
"mime-types": "^3.0.1",
|
||||||
"ms": "^2.1.3",
|
"ms": "^2.1.3",
|
||||||
"on-finished": "^2.4.1",
|
"on-finished": "^2.4.1",
|
||||||
"range-parser": "^1.2.1",
|
"range-parser": "^1.2.1",
|
||||||
@@ -3682,52 +3674,16 @@
|
|||||||
"node": ">= 18"
|
"node": ">= 18"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/send/node_modules/fresh": {
|
|
||||||
"version": "0.5.2",
|
|
||||||
"resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz",
|
|
||||||
"integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==",
|
|
||||||
"license": "MIT",
|
|
||||||
"engines": {
|
|
||||||
"node": ">= 0.6"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/send/node_modules/mime-db": {
|
|
||||||
"version": "1.52.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
|
|
||||||
"integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==",
|
|
||||||
"license": "MIT",
|
|
||||||
"engines": {
|
|
||||||
"node": ">= 0.6"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/send/node_modules/mime-types": {
|
|
||||||
"version": "2.1.35",
|
|
||||||
"resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz",
|
|
||||||
"integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"mime-db": "1.52.0"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">= 0.6"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/send/node_modules/ms": {
|
|
||||||
"version": "2.1.3",
|
|
||||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
|
||||||
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
|
|
||||||
"license": "MIT"
|
|
||||||
},
|
|
||||||
"node_modules/serve-static": {
|
"node_modules/serve-static": {
|
||||||
"version": "2.1.0",
|
"version": "2.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/serve-static/-/serve-static-2.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/serve-static/-/serve-static-2.2.0.tgz",
|
||||||
"integrity": "sha512-A3We5UfEjG8Z7VkDv6uItWw6HY2bBSBJT1KtVESn6EOoOr2jAxNhxWCLY3jDE2WcuHXByWju74ck3ZgLwL8xmA==",
|
"integrity": "sha512-61g9pCh0Vnh7IutZjtLGGpTA355+OPn2TyDv/6ivP2h/AdAVX9azsoxmg2/M6nZeQZNYBEwIcsne1mJd9oQItQ==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"encodeurl": "^2.0.0",
|
"encodeurl": "^2.0.0",
|
||||||
"escape-html": "^1.0.3",
|
"escape-html": "^1.0.3",
|
||||||
"parseurl": "^1.3.3",
|
"parseurl": "^1.3.3",
|
||||||
"send": "^1.0.0"
|
"send": "^1.2.0"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">= 18"
|
"node": ">= 18"
|
||||||
@@ -3792,7 +3748,6 @@
|
|||||||
"version": "2.0.0",
|
"version": "2.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
|
||||||
"integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==",
|
"integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"shebang-regex": "^3.0.0"
|
"shebang-regex": "^3.0.0"
|
||||||
@@ -3805,7 +3760,6 @@
|
|||||||
"version": "3.0.0",
|
"version": "3.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz",
|
||||||
"integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==",
|
"integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=8"
|
"node": ">=8"
|
||||||
@@ -4062,9 +4016,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/type-is": {
|
"node_modules/type-is": {
|
||||||
"version": "2.0.0",
|
"version": "2.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/type-is/-/type-is-2.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/type-is/-/type-is-2.0.1.tgz",
|
||||||
"integrity": "sha512-gd0sGezQYCbWSbkZr75mln4YBidWUN60+devscpLF5mtRDUpiaTvKpBNrdaCvel1NdR2k6vclXybU5fBd2i+nw==",
|
"integrity": "sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"content-type": "^1.0.5",
|
"content-type": "^1.0.5",
|
||||||
@@ -4188,6 +4142,8 @@
|
|||||||
},
|
},
|
||||||
"node_modules/undici-types": {
|
"node_modules/undici-types": {
|
||||||
"version": "6.20.0",
|
"version": "6.20.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.20.0.tgz",
|
||||||
|
"integrity": "sha512-Ny6QZ2Nju20vw1SRHe3d9jVu6gJ+4e3+MMpqu7pqE5HT6WsTSlce++GQmK5UXS8mzV8DSYHrQH+Xrf2jVcuKNg==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
@@ -4210,15 +4166,6 @@
|
|||||||
"punycode": "^2.1.0"
|
"punycode": "^2.1.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/utils-merge": {
|
|
||||||
"version": "1.0.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz",
|
|
||||||
"integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==",
|
|
||||||
"license": "MIT",
|
|
||||||
"engines": {
|
|
||||||
"node": ">= 0.4.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/vary": {
|
"node_modules/vary": {
|
||||||
"version": "1.1.2",
|
"version": "1.1.2",
|
||||||
"resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz",
|
||||||
@@ -4232,7 +4179,6 @@
|
|||||||
"version": "2.0.2",
|
"version": "2.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
|
||||||
"integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==",
|
"integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==",
|
||||||
"dev": true,
|
|
||||||
"license": "ISC",
|
"license": "ISC",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"isexe": "^2.0.0"
|
"isexe": "^2.0.0"
|
||||||
@@ -4349,16 +4295,25 @@
|
|||||||
"integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==",
|
"integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==",
|
||||||
"license": "ISC"
|
"license": "ISC"
|
||||||
},
|
},
|
||||||
"node_modules/yaml": {
|
"node_modules/ws": {
|
||||||
"version": "2.7.1",
|
"version": "8.18.1",
|
||||||
"resolved": "https://registry.npmjs.org/yaml/-/yaml-2.7.1.tgz",
|
"resolved": "https://registry.npmjs.org/ws/-/ws-8.18.1.tgz",
|
||||||
"integrity": "sha512-10ULxpnOCQXxJvBgxsn9ptjq6uviG/htZKk9veJGhlqn3w/DxQ631zFF+nlQXLwmImeS5amR2dl2U8sg6U9jsQ==",
|
"integrity": "sha512-RKW2aJZMXeMxVpnZ6bck+RswznaxmzdULiBr6KY7XkTnW8uvt0iT9H5DkHUChXrc+uurzwa0rVI16n/Xzjdz1w==",
|
||||||
"license": "ISC",
|
"license": "MIT",
|
||||||
"bin": {
|
|
||||||
"yaml": "bin.mjs"
|
|
||||||
},
|
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">= 14"
|
"node": ">=10.0.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"bufferutil": "^4.0.1",
|
||||||
|
"utf-8-validate": ">=5.0.2"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"bufferutil": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"utf-8-validate": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/yocto-queue": {
|
"node_modules/yocto-queue": {
|
||||||
@@ -4376,6 +4331,8 @@
|
|||||||
},
|
},
|
||||||
"node_modules/zod": {
|
"node_modules/zod": {
|
||||||
"version": "3.24.2",
|
"version": "3.24.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/zod/-/zod-3.24.2.tgz",
|
||||||
|
"integrity": "sha512-lY7CDW43ECgW9u1TcT3IoXHflywfVqDYze4waEz812jR/bZ8FHDsl7pFQoSZTz5N+2NqRXs8GBwnAwo3ZNxqhQ==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"funding": {
|
"funding": {
|
||||||
"url": "https://github.com/sponsors/colinhacks"
|
"url": "https://github.com/sponsors/colinhacks"
|
||||||
@@ -4383,6 +4340,8 @@
|
|||||||
},
|
},
|
||||||
"node_modules/zod-to-json-schema": {
|
"node_modules/zod-to-json-schema": {
|
||||||
"version": "3.24.4",
|
"version": "3.24.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/zod-to-json-schema/-/zod-to-json-schema-3.24.4.tgz",
|
||||||
|
"integrity": "sha512-0uNlcvgabyrni9Ag8Vghj21drk7+7tp7VTwwR7KxxXXc/3pbXz2PHlDgj3cICahgF1kHm4dExBFj7BXrZJXzig==",
|
||||||
"license": "ISC",
|
"license": "ISC",
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"zod": "^3.24.1"
|
"zod": "^3.24.1"
|
||||||
|
|||||||
23
package.json
23
package.json
@@ -1,7 +1,8 @@
|
|||||||
{
|
{
|
||||||
"name": "@playwright/mcp",
|
"name": "@playwright/mcp",
|
||||||
"version": "0.0.9",
|
"version": "0.0.30",
|
||||||
"description": "Playwright Tools for MCP",
|
"description": "Playwright Tools for MCP",
|
||||||
|
"type": "module",
|
||||||
"repository": {
|
"repository": {
|
||||||
"type": "git",
|
"type": "git",
|
||||||
"url": "git+https://github.com/microsoft/playwright-mcp.git"
|
"url": "git+https://github.com/microsoft/playwright-mcp.git"
|
||||||
@@ -16,9 +17,14 @@
|
|||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "tsc",
|
"build": "tsc",
|
||||||
"lint": "eslint .",
|
"lint": "npm run update-readme && eslint . && tsc --noEmit",
|
||||||
|
"update-readme": "node utils/update-readme.js",
|
||||||
"watch": "tsc --watch",
|
"watch": "tsc --watch",
|
||||||
"test": "playwright test",
|
"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",
|
"clean": "rm -rf lib",
|
||||||
"npm-publish": "npm run clean && npm run build && npm run test && npm publish"
|
"npm-publish": "npm run clean && npm run build && npm run test && npm publish"
|
||||||
},
|
},
|
||||||
@@ -30,18 +36,23 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@modelcontextprotocol/sdk": "^1.6.1",
|
"@modelcontextprotocol/sdk": "^1.11.0",
|
||||||
"commander": "^13.1.0",
|
"commander": "^13.1.0",
|
||||||
"playwright": "^1.52.0-alpha-1743163434000",
|
"debug": "^4.4.1",
|
||||||
"yaml": "^2.7.1",
|
"mime": "^4.0.7",
|
||||||
|
"playwright": "1.54.1",
|
||||||
|
"ws": "^8.18.1",
|
||||||
"zod-to-json-schema": "^3.24.4"
|
"zod-to-json-schema": "^3.24.4"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/eslintrc": "^3.2.0",
|
"@eslint/eslintrc": "^3.2.0",
|
||||||
"@eslint/js": "^9.19.0",
|
"@eslint/js": "^9.19.0",
|
||||||
"@playwright/test": "^1.52.0-alpha-1743163434000",
|
"@playwright/test": "1.54.1",
|
||||||
"@stylistic/eslint-plugin": "^3.0.1",
|
"@stylistic/eslint-plugin": "^3.0.1",
|
||||||
|
"@types/chrome": "^0.0.315",
|
||||||
|
"@types/debug": "^4.1.12",
|
||||||
"@types/node": "^22.13.10",
|
"@types/node": "^22.13.10",
|
||||||
|
"@types/ws": "^8.18.1",
|
||||||
"@typescript-eslint/eslint-plugin": "^8.26.1",
|
"@typescript-eslint/eslint-plugin": "^8.26.1",
|
||||||
"@typescript-eslint/parser": "^8.26.1",
|
"@typescript-eslint/parser": "^8.26.1",
|
||||||
"@typescript-eslint/utils": "^8.26.1",
|
"@typescript-eslint/utils": "^8.26.1",
|
||||||
|
|||||||
@@ -16,12 +16,28 @@
|
|||||||
|
|
||||||
import { defineConfig } from '@playwright/test';
|
import { defineConfig } from '@playwright/test';
|
||||||
|
|
||||||
export default defineConfig({
|
import type { TestOptions } from './tests/fixtures.js';
|
||||||
|
|
||||||
|
export default defineConfig<TestOptions>({
|
||||||
testDir: './tests',
|
testDir: './tests',
|
||||||
fullyParallel: true,
|
fullyParallel: true,
|
||||||
forbidOnly: !!process.env.CI,
|
forbidOnly: !!process.env.CI,
|
||||||
retries: process.env.CI ? 2 : 0,
|
retries: process.env.CI ? 2 : 0,
|
||||||
workers: process.env.CI ? 1 : undefined,
|
workers: process.env.CI ? 1 : undefined,
|
||||||
reporter: 'list',
|
reporter: 'list',
|
||||||
projects: [{ name: 'default' }],
|
projects: [
|
||||||
|
{ name: 'chrome' },
|
||||||
|
{ name: 'msedge', use: { mcpBrowser: 'msedge' } },
|
||||||
|
{ name: 'chromium', use: { mcpBrowser: 'chromium' } },
|
||||||
|
...process.env.MCP_IN_DOCKER ? [{
|
||||||
|
name: 'chromium-docker',
|
||||||
|
grep: /browser_navigate|browser_click/,
|
||||||
|
use: {
|
||||||
|
mcpBrowser: 'chromium',
|
||||||
|
mcpMode: 'docker' as const
|
||||||
|
}
|
||||||
|
}] : [],
|
||||||
|
{ name: 'firefox', use: { mcpBrowser: 'firefox' } },
|
||||||
|
{ name: 'webkit', use: { mcpBrowser: 'webkit' } },
|
||||||
|
],
|
||||||
});
|
});
|
||||||
|
|||||||
266
src/browserContextFactory.ts
Normal file
266
src/browserContextFactory.ts
Normal file
@@ -0,0 +1,266 @@
|
|||||||
|
/**
|
||||||
|
* Copyright (c) Microsoft Corporation.
|
||||||
|
*
|
||||||
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
* you may not use this file except in compliance with the License.
|
||||||
|
* You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing, software
|
||||||
|
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
* See the License for the specific language governing permissions and
|
||||||
|
* limitations under the License.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import fs from 'node:fs';
|
||||||
|
import net from 'node:net';
|
||||||
|
import path from 'node:path';
|
||||||
|
import os from 'node:os';
|
||||||
|
|
||||||
|
import debug from 'debug';
|
||||||
|
import * as playwright from 'playwright';
|
||||||
|
import { userDataDir } from './fileUtils.js';
|
||||||
|
|
||||||
|
import type { FullConfig } from './config.js';
|
||||||
|
import type { BrowserInfo, LaunchBrowserRequest } from './browserServer.js';
|
||||||
|
|
||||||
|
const testDebug = debug('pw:mcp:test');
|
||||||
|
|
||||||
|
export function contextFactory(browserConfig: FullConfig['browser']): BrowserContextFactory {
|
||||||
|
if (browserConfig.remoteEndpoint)
|
||||||
|
return new RemoteContextFactory(browserConfig);
|
||||||
|
if (browserConfig.cdpEndpoint)
|
||||||
|
return new CdpContextFactory(browserConfig);
|
||||||
|
if (browserConfig.isolated)
|
||||||
|
return new IsolatedContextFactory(browserConfig);
|
||||||
|
if (browserConfig.browserAgent)
|
||||||
|
return new BrowserServerContextFactory(browserConfig);
|
||||||
|
return new PersistentContextFactory(browserConfig);
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BrowserContextFactory {
|
||||||
|
createContext(): Promise<{ browserContext: playwright.BrowserContext, close: () => Promise<void> }>;
|
||||||
|
}
|
||||||
|
|
||||||
|
class BaseContextFactory implements BrowserContextFactory {
|
||||||
|
readonly browserConfig: FullConfig['browser'];
|
||||||
|
protected _browserPromise: Promise<playwright.Browser> | undefined;
|
||||||
|
readonly name: string;
|
||||||
|
|
||||||
|
constructor(name: string, browserConfig: FullConfig['browser']) {
|
||||||
|
this.name = name;
|
||||||
|
this.browserConfig = browserConfig;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected async _obtainBrowser(): Promise<playwright.Browser> {
|
||||||
|
if (this._browserPromise)
|
||||||
|
return this._browserPromise;
|
||||||
|
testDebug(`obtain browser (${this.name})`);
|
||||||
|
this._browserPromise = this._doObtainBrowser();
|
||||||
|
void this._browserPromise.then(browser => {
|
||||||
|
browser.on('disconnected', () => {
|
||||||
|
this._browserPromise = undefined;
|
||||||
|
});
|
||||||
|
}).catch(() => {
|
||||||
|
this._browserPromise = undefined;
|
||||||
|
});
|
||||||
|
return this._browserPromise;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected async _doObtainBrowser(): Promise<playwright.Browser> {
|
||||||
|
throw new Error('Not implemented');
|
||||||
|
}
|
||||||
|
|
||||||
|
async createContext(): Promise<{ browserContext: playwright.BrowserContext, close: () => Promise<void> }> {
|
||||||
|
testDebug(`create browser context (${this.name})`);
|
||||||
|
const browser = await this._obtainBrowser();
|
||||||
|
const browserContext = await this._doCreateContext(browser);
|
||||||
|
return { browserContext, close: () => this._closeBrowserContext(browserContext, browser) };
|
||||||
|
}
|
||||||
|
|
||||||
|
protected async _doCreateContext(browser: playwright.Browser): Promise<playwright.BrowserContext> {
|
||||||
|
throw new Error('Not implemented');
|
||||||
|
}
|
||||||
|
|
||||||
|
private async _closeBrowserContext(browserContext: playwright.BrowserContext, browser: playwright.Browser) {
|
||||||
|
testDebug(`close browser context (${this.name})`);
|
||||||
|
if (browser.contexts().length === 1)
|
||||||
|
this._browserPromise = undefined;
|
||||||
|
await browserContext.close().catch(() => {});
|
||||||
|
if (browser.contexts().length === 0) {
|
||||||
|
testDebug(`close browser (${this.name})`);
|
||||||
|
await browser.close().catch(() => {});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class IsolatedContextFactory extends BaseContextFactory {
|
||||||
|
constructor(browserConfig: FullConfig['browser']) {
|
||||||
|
super('isolated', browserConfig);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected override async _doObtainBrowser(): Promise<playwright.Browser> {
|
||||||
|
await injectCdpPort(this.browserConfig);
|
||||||
|
const browserType = playwright[this.browserConfig.browserName];
|
||||||
|
return browserType.launch({
|
||||||
|
...this.browserConfig.launchOptions,
|
||||||
|
handleSIGINT: false,
|
||||||
|
handleSIGTERM: false,
|
||||||
|
}).catch(error => {
|
||||||
|
if (error.message.includes('Executable doesn\'t exist'))
|
||||||
|
throw new Error(`Browser specified in your config is not installed. Either install it (likely) or change the config.`);
|
||||||
|
throw error;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
protected override async _doCreateContext(browser: playwright.Browser): Promise<playwright.BrowserContext> {
|
||||||
|
return browser.newContext(this.browserConfig.contextOptions);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class CdpContextFactory extends BaseContextFactory {
|
||||||
|
constructor(browserConfig: FullConfig['browser']) {
|
||||||
|
super('cdp', browserConfig);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected override async _doObtainBrowser(): Promise<playwright.Browser> {
|
||||||
|
return playwright.chromium.connectOverCDP(this.browserConfig.cdpEndpoint!);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected override async _doCreateContext(browser: playwright.Browser): Promise<playwright.BrowserContext> {
|
||||||
|
return this.browserConfig.isolated ? await browser.newContext() : browser.contexts()[0];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class RemoteContextFactory extends BaseContextFactory {
|
||||||
|
constructor(browserConfig: FullConfig['browser']) {
|
||||||
|
super('remote', browserConfig);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected override async _doObtainBrowser(): Promise<playwright.Browser> {
|
||||||
|
const url = new URL(this.browserConfig.remoteEndpoint!);
|
||||||
|
url.searchParams.set('browser', this.browserConfig.browserName);
|
||||||
|
if (this.browserConfig.launchOptions)
|
||||||
|
url.searchParams.set('launch-options', JSON.stringify(this.browserConfig.launchOptions));
|
||||||
|
return playwright[this.browserConfig.browserName].connect(String(url));
|
||||||
|
}
|
||||||
|
|
||||||
|
protected override async _doCreateContext(browser: playwright.Browser): Promise<playwright.BrowserContext> {
|
||||||
|
return browser.newContext();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class PersistentContextFactory implements BrowserContextFactory {
|
||||||
|
readonly browserConfig: FullConfig['browser'];
|
||||||
|
private _userDataDirs = new Set<string>();
|
||||||
|
|
||||||
|
constructor(browserConfig: FullConfig['browser']) {
|
||||||
|
this.browserConfig = browserConfig;
|
||||||
|
}
|
||||||
|
|
||||||
|
async createContext(): Promise<{ browserContext: playwright.BrowserContext, close: () => Promise<void> }> {
|
||||||
|
await injectCdpPort(this.browserConfig);
|
||||||
|
testDebug('create browser context (persistent)');
|
||||||
|
const userDataDir = this.browserConfig.userDataDir ?? await this._createUserDataDir();
|
||||||
|
|
||||||
|
this._userDataDirs.add(userDataDir);
|
||||||
|
testDebug('lock user data dir', userDataDir);
|
||||||
|
|
||||||
|
const browserType = playwright[this.browserConfig.browserName];
|
||||||
|
for (let i = 0; i < 5; i++) {
|
||||||
|
try {
|
||||||
|
const browserContext = await browserType.launchPersistentContext(userDataDir, {
|
||||||
|
...this.browserConfig.launchOptions,
|
||||||
|
...this.browserConfig.contextOptions,
|
||||||
|
handleSIGINT: false,
|
||||||
|
handleSIGTERM: false,
|
||||||
|
});
|
||||||
|
const close = () => this._closeBrowserContext(browserContext, userDataDir);
|
||||||
|
return { browserContext, close };
|
||||||
|
} catch (error: any) {
|
||||||
|
if (error.message.includes('Executable doesn\'t exist'))
|
||||||
|
throw new Error(`Browser specified in your config is not installed. Either install it (likely) or change the config.`);
|
||||||
|
if (error.message.includes('ProcessSingleton') || error.message.includes('Invalid URL')) {
|
||||||
|
// User data directory is already in use, try again.
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 1000));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw new Error(`Browser is already in use for ${userDataDir}, use --isolated to run multiple instances of the same browser`);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async _closeBrowserContext(browserContext: playwright.BrowserContext, userDataDir: string) {
|
||||||
|
testDebug('close browser context (persistent)');
|
||||||
|
testDebug('release user data dir', userDataDir);
|
||||||
|
await browserContext.close().catch(() => {});
|
||||||
|
this._userDataDirs.delete(userDataDir);
|
||||||
|
testDebug('close browser context complete (persistent)');
|
||||||
|
}
|
||||||
|
|
||||||
|
private async _createUserDataDir() {
|
||||||
|
let cacheDirectory: string;
|
||||||
|
if (process.platform === 'linux')
|
||||||
|
cacheDirectory = process.env.XDG_CACHE_HOME || path.join(os.homedir(), '.cache');
|
||||||
|
else if (process.platform === 'darwin')
|
||||||
|
cacheDirectory = path.join(os.homedir(), 'Library', 'Caches');
|
||||||
|
else if (process.platform === 'win32')
|
||||||
|
cacheDirectory = process.env.LOCALAPPDATA || path.join(os.homedir(), 'AppData', 'Local');
|
||||||
|
else
|
||||||
|
throw new Error('Unsupported platform: ' + process.platform);
|
||||||
|
const result = path.join(cacheDirectory, 'ms-playwright', `mcp-${this.browserConfig.launchOptions?.channel ?? this.browserConfig?.browserName}-profile`);
|
||||||
|
await fs.promises.mkdir(result, { recursive: true });
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class BrowserServerContextFactory extends BaseContextFactory {
|
||||||
|
constructor(browserConfig: FullConfig['browser']) {
|
||||||
|
super('persistent', browserConfig);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected override async _doObtainBrowser(): Promise<playwright.Browser> {
|
||||||
|
const response = await fetch(new URL(`/json/launch`, this.browserConfig.browserAgent), {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({
|
||||||
|
browserType: this.browserConfig.browserName,
|
||||||
|
userDataDir: this.browserConfig.userDataDir ?? await this._createUserDataDir(),
|
||||||
|
launchOptions: this.browserConfig.launchOptions,
|
||||||
|
contextOptions: this.browserConfig.contextOptions,
|
||||||
|
} as LaunchBrowserRequest),
|
||||||
|
});
|
||||||
|
const info = await response.json() as BrowserInfo;
|
||||||
|
if (info.error)
|
||||||
|
throw new Error(info.error);
|
||||||
|
return await playwright.chromium.connectOverCDP(`http://localhost:${info.cdpPort}/`);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected override async _doCreateContext(browser: playwright.Browser): Promise<playwright.BrowserContext> {
|
||||||
|
return this.browserConfig.isolated ? await browser.newContext() : browser.contexts()[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
private async _createUserDataDir() {
|
||||||
|
const dir = await userDataDir(this.browserConfig);
|
||||||
|
await fs.promises.mkdir(dir, { recursive: true });
|
||||||
|
return dir;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function injectCdpPort(browserConfig: FullConfig['browser']) {
|
||||||
|
if (browserConfig.browserName === 'chromium')
|
||||||
|
(browserConfig.launchOptions as any).cdpPort = await findFreePort();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function findFreePort() {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const server = net.createServer();
|
||||||
|
server.listen(0, () => {
|
||||||
|
const { port } = server.address() as net.AddressInfo;
|
||||||
|
server.close(() => resolve(port));
|
||||||
|
});
|
||||||
|
server.on('error', reject);
|
||||||
|
});
|
||||||
|
}
|
||||||
197
src/browserServer.ts
Normal file
197
src/browserServer.ts
Normal file
@@ -0,0 +1,197 @@
|
|||||||
|
/**
|
||||||
|
* 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 net from 'net';
|
||||||
|
|
||||||
|
import { program } from 'commander';
|
||||||
|
import playwright from 'playwright';
|
||||||
|
|
||||||
|
import { HttpServer } from './httpServer.js';
|
||||||
|
import { packageJSON } from './package.js';
|
||||||
|
|
||||||
|
import type http from 'http';
|
||||||
|
|
||||||
|
export type LaunchBrowserRequest = {
|
||||||
|
browserType: string;
|
||||||
|
userDataDir: string;
|
||||||
|
launchOptions: playwright.LaunchOptions;
|
||||||
|
contextOptions: playwright.BrowserContextOptions;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type BrowserInfo = {
|
||||||
|
browserType: string;
|
||||||
|
userDataDir: string;
|
||||||
|
cdpPort: number;
|
||||||
|
launchOptions: playwright.LaunchOptions;
|
||||||
|
contextOptions: playwright.BrowserContextOptions;
|
||||||
|
error?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type BrowserEntry = {
|
||||||
|
browser?: playwright.Browser;
|
||||||
|
info: BrowserInfo;
|
||||||
|
};
|
||||||
|
|
||||||
|
class BrowserServer {
|
||||||
|
private _server = new HttpServer();
|
||||||
|
private _entries: BrowserEntry[] = [];
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
this._setupExitHandler();
|
||||||
|
}
|
||||||
|
|
||||||
|
async start(port: number) {
|
||||||
|
await this._server.start({ port });
|
||||||
|
this._server.routePath('/json/list', (req, res) => {
|
||||||
|
this._handleJsonList(res);
|
||||||
|
});
|
||||||
|
this._server.routePath('/json/launch', async (req, res) => {
|
||||||
|
void this._handleLaunchBrowser(req, res).catch(e => console.error(e));
|
||||||
|
});
|
||||||
|
this._setEntries([]);
|
||||||
|
}
|
||||||
|
|
||||||
|
private _handleJsonList(res: http.ServerResponse) {
|
||||||
|
const list = this._entries.map(browser => browser.info);
|
||||||
|
res.end(JSON.stringify(list));
|
||||||
|
}
|
||||||
|
|
||||||
|
private async _handleLaunchBrowser(req: http.IncomingMessage, res: http.ServerResponse) {
|
||||||
|
const request = await readBody<LaunchBrowserRequest>(req);
|
||||||
|
let info = this._entries.map(entry => entry.info).find(info => info.userDataDir === request.userDataDir);
|
||||||
|
if (!info || info.error)
|
||||||
|
info = await this._newBrowser(request);
|
||||||
|
res.end(JSON.stringify(info));
|
||||||
|
}
|
||||||
|
|
||||||
|
private async _newBrowser(request: LaunchBrowserRequest): Promise<BrowserInfo> {
|
||||||
|
const cdpPort = await findFreePort();
|
||||||
|
(request.launchOptions as any).cdpPort = cdpPort;
|
||||||
|
const info: BrowserInfo = {
|
||||||
|
browserType: request.browserType,
|
||||||
|
userDataDir: request.userDataDir,
|
||||||
|
cdpPort,
|
||||||
|
launchOptions: request.launchOptions,
|
||||||
|
contextOptions: request.contextOptions,
|
||||||
|
};
|
||||||
|
|
||||||
|
const browserType = playwright[request.browserType as 'chromium' | 'firefox' | 'webkit'];
|
||||||
|
const { browser, error } = await browserType.launchPersistentContext(request.userDataDir, {
|
||||||
|
...request.launchOptions,
|
||||||
|
...request.contextOptions,
|
||||||
|
handleSIGINT: false,
|
||||||
|
handleSIGTERM: false,
|
||||||
|
}).then(context => {
|
||||||
|
return { browser: context.browser()!, error: undefined };
|
||||||
|
}).catch(error => {
|
||||||
|
return { browser: undefined, error: error.message };
|
||||||
|
});
|
||||||
|
this._setEntries([...this._entries, {
|
||||||
|
browser,
|
||||||
|
info: {
|
||||||
|
browserType: request.browserType,
|
||||||
|
userDataDir: request.userDataDir,
|
||||||
|
cdpPort,
|
||||||
|
launchOptions: request.launchOptions,
|
||||||
|
contextOptions: request.contextOptions,
|
||||||
|
error,
|
||||||
|
},
|
||||||
|
}]);
|
||||||
|
browser?.on('disconnected', () => {
|
||||||
|
this._setEntries(this._entries.filter(entry => entry.browser !== browser));
|
||||||
|
});
|
||||||
|
return info;
|
||||||
|
}
|
||||||
|
|
||||||
|
private _updateReport() {
|
||||||
|
// Clear the current line and move cursor to top of screen
|
||||||
|
process.stdout.write('\x1b[2J\x1b[H');
|
||||||
|
process.stdout.write(`Playwright Browser Server v${packageJSON.version}\n`);
|
||||||
|
process.stdout.write(`Listening on ${this._server.urlPrefix('human-readable')}\n\n`);
|
||||||
|
|
||||||
|
if (this._entries.length === 0) {
|
||||||
|
process.stdout.write('No browsers currently running\n');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
process.stdout.write('Running browsers:\n');
|
||||||
|
for (const entry of this._entries) {
|
||||||
|
const status = entry.browser ? 'running' : 'error';
|
||||||
|
const statusColor = entry.browser ? '\x1b[32m' : '\x1b[31m'; // green for running, red for error
|
||||||
|
process.stdout.write(`${statusColor}${entry.info.browserType}\x1b[0m (${entry.info.userDataDir}) - ${statusColor}${status}\x1b[0m\n`);
|
||||||
|
if (entry.info.error)
|
||||||
|
process.stdout.write(` Error: ${entry.info.error}\n`);
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
private _setEntries(entries: BrowserEntry[]) {
|
||||||
|
this._entries = entries;
|
||||||
|
this._updateReport();
|
||||||
|
}
|
||||||
|
|
||||||
|
private _setupExitHandler() {
|
||||||
|
let isExiting = false;
|
||||||
|
const handleExit = async () => {
|
||||||
|
if (isExiting)
|
||||||
|
return;
|
||||||
|
isExiting = true;
|
||||||
|
setTimeout(() => process.exit(0), 15000);
|
||||||
|
for (const entry of this._entries)
|
||||||
|
await entry.browser?.close().catch(() => {});
|
||||||
|
process.exit(0);
|
||||||
|
};
|
||||||
|
|
||||||
|
process.stdin.on('close', handleExit);
|
||||||
|
process.on('SIGINT', handleExit);
|
||||||
|
process.on('SIGTERM', handleExit);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
program
|
||||||
|
.name('browser-agent')
|
||||||
|
.option('-p, --port <port>', 'Port to listen on', '9224')
|
||||||
|
.action(async options => {
|
||||||
|
await main(options);
|
||||||
|
});
|
||||||
|
|
||||||
|
void program.parseAsync(process.argv);
|
||||||
|
|
||||||
|
async function main(options: { port: string }) {
|
||||||
|
const server = new BrowserServer();
|
||||||
|
await server.start(+options.port);
|
||||||
|
}
|
||||||
|
|
||||||
|
function readBody<T>(req: http.IncomingMessage): Promise<T> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const chunks: Buffer[] = [];
|
||||||
|
req.on('data', (chunk: Buffer) => chunks.push(chunk));
|
||||||
|
req.on('end', () => resolve(JSON.parse(Buffer.concat(chunks).toString())));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
|
});
|
||||||
|
}
|
||||||
257
src/config.ts
Normal file
257
src/config.ts
Normal file
@@ -0,0 +1,257 @@
|
|||||||
|
/**
|
||||||
|
* Copyright (c) Microsoft Corporation.
|
||||||
|
*
|
||||||
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
* you may not use this file except in compliance with the License.
|
||||||
|
* You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing, software
|
||||||
|
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
* See the License for the specific language governing permissions and
|
||||||
|
* limitations under the License.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import fs from 'fs';
|
||||||
|
import os from 'os';
|
||||||
|
import path from 'path';
|
||||||
|
import { devices } from 'playwright';
|
||||||
|
|
||||||
|
import type { Config, ToolCapability } from '../config.js';
|
||||||
|
import type { BrowserContextOptions, LaunchOptions } from 'playwright';
|
||||||
|
import { sanitizeForFilePath } from './tools/utils.js';
|
||||||
|
|
||||||
|
export type CLIOptions = {
|
||||||
|
allowedOrigins?: string[];
|
||||||
|
blockedOrigins?: string[];
|
||||||
|
blockServiceWorkers?: boolean;
|
||||||
|
browser?: string;
|
||||||
|
browserAgent?: string;
|
||||||
|
caps?: string;
|
||||||
|
cdpEndpoint?: string;
|
||||||
|
config?: string;
|
||||||
|
device?: string;
|
||||||
|
executablePath?: string;
|
||||||
|
headless?: boolean;
|
||||||
|
host?: string;
|
||||||
|
ignoreHttpsErrors?: boolean;
|
||||||
|
isolated?: boolean;
|
||||||
|
imageResponses?: 'allow' | 'omit' | 'auto';
|
||||||
|
sandbox: boolean;
|
||||||
|
outputDir?: string;
|
||||||
|
port?: number;
|
||||||
|
proxyBypass?: string;
|
||||||
|
proxyServer?: string;
|
||||||
|
saveTrace?: boolean;
|
||||||
|
storageState?: string;
|
||||||
|
userAgent?: string;
|
||||||
|
userDataDir?: string;
|
||||||
|
viewportSize?: string;
|
||||||
|
vision?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
const defaultConfig: FullConfig = {
|
||||||
|
browser: {
|
||||||
|
browserName: 'chromium',
|
||||||
|
launchOptions: {
|
||||||
|
channel: 'chrome',
|
||||||
|
headless: os.platform() === 'linux' && !process.env.DISPLAY,
|
||||||
|
chromiumSandbox: true,
|
||||||
|
},
|
||||||
|
contextOptions: {
|
||||||
|
viewport: null,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
network: {
|
||||||
|
allowedOrigins: undefined,
|
||||||
|
blockedOrigins: undefined,
|
||||||
|
},
|
||||||
|
server: {},
|
||||||
|
outputDir: path.join(os.tmpdir(), 'playwright-mcp-output', sanitizeForFilePath(new Date().toISOString())),
|
||||||
|
};
|
||||||
|
|
||||||
|
type BrowserUserConfig = NonNullable<Config['browser']>;
|
||||||
|
|
||||||
|
export type FullConfig = Config & {
|
||||||
|
browser: Omit<BrowserUserConfig, 'browserName'> & {
|
||||||
|
browserName: 'chromium' | 'firefox' | 'webkit';
|
||||||
|
launchOptions: NonNullable<BrowserUserConfig['launchOptions']>;
|
||||||
|
contextOptions: NonNullable<BrowserUserConfig['contextOptions']>;
|
||||||
|
},
|
||||||
|
network: NonNullable<Config['network']>,
|
||||||
|
outputDir: string;
|
||||||
|
server: NonNullable<Config['server']>,
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function resolveConfig(config: Config): Promise<FullConfig> {
|
||||||
|
return mergeConfig(defaultConfig, config);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function resolveCLIConfig(cliOptions: CLIOptions): Promise<FullConfig> {
|
||||||
|
const configInFile = await loadConfig(cliOptions.config);
|
||||||
|
const cliOverrides = await configFromCLIOptions(cliOptions);
|
||||||
|
const result = mergeConfig(mergeConfig(defaultConfig, configInFile), cliOverrides);
|
||||||
|
// Derive artifact output directory from config.outputDir
|
||||||
|
if (result.saveTrace)
|
||||||
|
result.browser.launchOptions.tracesDir = path.join(result.outputDir, 'traces');
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function configFromCLIOptions(cliOptions: CLIOptions): Promise<Config> {
|
||||||
|
let browserName: 'chromium' | 'firefox' | 'webkit' | undefined;
|
||||||
|
let channel: string | undefined;
|
||||||
|
switch (cliOptions.browser) {
|
||||||
|
case 'chrome':
|
||||||
|
case 'chrome-beta':
|
||||||
|
case 'chrome-canary':
|
||||||
|
case 'chrome-dev':
|
||||||
|
case 'chromium':
|
||||||
|
case 'msedge':
|
||||||
|
case 'msedge-beta':
|
||||||
|
case 'msedge-canary':
|
||||||
|
case 'msedge-dev':
|
||||||
|
browserName = 'chromium';
|
||||||
|
channel = cliOptions.browser;
|
||||||
|
break;
|
||||||
|
case 'firefox':
|
||||||
|
browserName = 'firefox';
|
||||||
|
break;
|
||||||
|
case 'webkit':
|
||||||
|
browserName = 'webkit';
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Launch options
|
||||||
|
const launchOptions: LaunchOptions = {
|
||||||
|
channel,
|
||||||
|
executablePath: cliOptions.executablePath,
|
||||||
|
headless: cliOptions.headless,
|
||||||
|
};
|
||||||
|
|
||||||
|
// --no-sandbox was passed, disable the sandbox
|
||||||
|
if (!cliOptions.sandbox)
|
||||||
|
launchOptions.chromiumSandbox = false;
|
||||||
|
|
||||||
|
if (cliOptions.proxyServer) {
|
||||||
|
launchOptions.proxy = {
|
||||||
|
server: cliOptions.proxyServer
|
||||||
|
};
|
||||||
|
if (cliOptions.proxyBypass)
|
||||||
|
launchOptions.proxy.bypass = cliOptions.proxyBypass;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (cliOptions.device && cliOptions.cdpEndpoint)
|
||||||
|
throw new Error('Device emulation is not supported with cdpEndpoint.');
|
||||||
|
|
||||||
|
// Context options
|
||||||
|
const contextOptions: BrowserContextOptions = cliOptions.device ? devices[cliOptions.device] : {};
|
||||||
|
if (cliOptions.storageState)
|
||||||
|
contextOptions.storageState = cliOptions.storageState;
|
||||||
|
|
||||||
|
if (cliOptions.userAgent)
|
||||||
|
contextOptions.userAgent = cliOptions.userAgent;
|
||||||
|
|
||||||
|
if (cliOptions.viewportSize) {
|
||||||
|
try {
|
||||||
|
const [width, height] = cliOptions.viewportSize.split(',').map(n => +n);
|
||||||
|
if (isNaN(width) || isNaN(height))
|
||||||
|
throw new Error('bad values');
|
||||||
|
contextOptions.viewport = { width, height };
|
||||||
|
} catch (e) {
|
||||||
|
throw new Error('Invalid viewport size format: use "width,height", for example --viewport-size="800,600"');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (cliOptions.ignoreHttpsErrors)
|
||||||
|
contextOptions.ignoreHTTPSErrors = true;
|
||||||
|
|
||||||
|
if (cliOptions.blockServiceWorkers)
|
||||||
|
contextOptions.serviceWorkers = 'block';
|
||||||
|
|
||||||
|
const result: Config = {
|
||||||
|
browser: {
|
||||||
|
browserAgent: cliOptions.browserAgent ?? process.env.PW_BROWSER_AGENT,
|
||||||
|
browserName,
|
||||||
|
isolated: cliOptions.isolated,
|
||||||
|
userDataDir: cliOptions.userDataDir,
|
||||||
|
launchOptions,
|
||||||
|
contextOptions,
|
||||||
|
cdpEndpoint: cliOptions.cdpEndpoint,
|
||||||
|
},
|
||||||
|
server: {
|
||||||
|
port: cliOptions.port,
|
||||||
|
host: cliOptions.host,
|
||||||
|
},
|
||||||
|
capabilities: cliOptions.caps?.split(',').map((c: string) => c.trim() as ToolCapability),
|
||||||
|
vision: !!cliOptions.vision,
|
||||||
|
network: {
|
||||||
|
allowedOrigins: cliOptions.allowedOrigins,
|
||||||
|
blockedOrigins: cliOptions.blockedOrigins,
|
||||||
|
},
|
||||||
|
saveTrace: cliOptions.saveTrace,
|
||||||
|
outputDir: cliOptions.outputDir,
|
||||||
|
imageResponses: cliOptions.imageResponses,
|
||||||
|
};
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadConfig(configFile: string | undefined): Promise<Config> {
|
||||||
|
if (!configFile)
|
||||||
|
return {};
|
||||||
|
|
||||||
|
try {
|
||||||
|
return JSON.parse(await fs.promises.readFile(configFile, 'utf8'));
|
||||||
|
} catch (error) {
|
||||||
|
throw new Error(`Failed to load config file: ${configFile}, ${error}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function outputFile(config: FullConfig, name: string): Promise<string> {
|
||||||
|
await fs.promises.mkdir(config.outputDir, { recursive: true });
|
||||||
|
const fileName = sanitizeForFilePath(name);
|
||||||
|
return path.join(config.outputDir, fileName);
|
||||||
|
}
|
||||||
|
|
||||||
|
function pickDefined<T extends object>(obj: T | undefined): Partial<T> {
|
||||||
|
return Object.fromEntries(
|
||||||
|
Object.entries(obj ?? {}).filter(([_, v]) => v !== undefined)
|
||||||
|
) as Partial<T>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function mergeConfig(base: FullConfig, overrides: Config): FullConfig {
|
||||||
|
const browser: FullConfig['browser'] = {
|
||||||
|
...pickDefined(base.browser),
|
||||||
|
...pickDefined(overrides.browser),
|
||||||
|
browserName: overrides.browser?.browserName ?? base.browser?.browserName ?? 'chromium',
|
||||||
|
isolated: overrides.browser?.isolated ?? base.browser?.isolated ?? false,
|
||||||
|
launchOptions: {
|
||||||
|
...pickDefined(base.browser?.launchOptions),
|
||||||
|
...pickDefined(overrides.browser?.launchOptions),
|
||||||
|
...{ assistantMode: true },
|
||||||
|
},
|
||||||
|
contextOptions: {
|
||||||
|
...pickDefined(base.browser?.contextOptions),
|
||||||
|
...pickDefined(overrides.browser?.contextOptions),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
if (browser.browserName !== 'chromium' && browser.launchOptions)
|
||||||
|
delete browser.launchOptions.channel;
|
||||||
|
|
||||||
|
return {
|
||||||
|
...pickDefined(base),
|
||||||
|
...pickDefined(overrides),
|
||||||
|
browser,
|
||||||
|
network: {
|
||||||
|
...pickDefined(base.network),
|
||||||
|
...pickDefined(overrides.network),
|
||||||
|
},
|
||||||
|
server: {
|
||||||
|
...pickDefined(base.server),
|
||||||
|
...pickDefined(overrides.server),
|
||||||
|
},
|
||||||
|
} as FullConfig;
|
||||||
|
}
|
||||||
97
src/connection.ts
Normal file
97
src/connection.ts
Normal file
@@ -0,0 +1,97 @@
|
|||||||
|
/**
|
||||||
|
* Copyright (c) Microsoft Corporation.
|
||||||
|
*
|
||||||
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
* you may not use this file except in compliance with the License.
|
||||||
|
* You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing, software
|
||||||
|
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
* See the License for the specific language governing permissions and
|
||||||
|
* limitations under the License.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Server as McpServer } from '@modelcontextprotocol/sdk/server/index.js';
|
||||||
|
import { CallToolRequestSchema, ListToolsRequestSchema, Tool as McpTool } from '@modelcontextprotocol/sdk/types.js';
|
||||||
|
import { zodToJsonSchema } from 'zod-to-json-schema';
|
||||||
|
|
||||||
|
import { Context } from './context.js';
|
||||||
|
import { snapshotTools, visionTools } from './tools.js';
|
||||||
|
import { packageJSON } from './package.js';
|
||||||
|
|
||||||
|
import { FullConfig } from './config.js';
|
||||||
|
|
||||||
|
import type { BrowserContextFactory } from './browserContextFactory.js';
|
||||||
|
|
||||||
|
export function createConnection(config: FullConfig, browserContextFactory: BrowserContextFactory): Connection {
|
||||||
|
const allTools = config.vision ? visionTools : snapshotTools;
|
||||||
|
const tools = allTools.filter(tool => !config.capabilities || tool.capability === 'core' || config.capabilities.includes(tool.capability));
|
||||||
|
const context = new Context(tools, config, browserContextFactory);
|
||||||
|
const server = new McpServer({ name: 'Playwright', version: packageJSON.version }, {
|
||||||
|
capabilities: {
|
||||||
|
tools: {},
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
server.setRequestHandler(ListToolsRequestSchema, async () => {
|
||||||
|
return {
|
||||||
|
tools: tools.map(tool => ({
|
||||||
|
name: tool.schema.name,
|
||||||
|
description: tool.schema.description,
|
||||||
|
inputSchema: zodToJsonSchema(tool.schema.inputSchema),
|
||||||
|
annotations: {
|
||||||
|
title: tool.schema.title,
|
||||||
|
readOnlyHint: tool.schema.type === 'readOnly',
|
||||||
|
destructiveHint: tool.schema.type === 'destructive',
|
||||||
|
openWorldHint: true,
|
||||||
|
},
|
||||||
|
})) as McpTool[],
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
server.setRequestHandler(CallToolRequestSchema, async request => {
|
||||||
|
const errorResult = (...messages: string[]) => ({
|
||||||
|
content: [{ type: 'text', text: messages.join('\n') }],
|
||||||
|
isError: true,
|
||||||
|
});
|
||||||
|
const tool = tools.find(tool => tool.schema.name === request.params.name);
|
||||||
|
if (!tool)
|
||||||
|
return errorResult(`Tool "${request.params.name}" not found`);
|
||||||
|
|
||||||
|
|
||||||
|
const modalStates = context.modalStates().map(state => state.type);
|
||||||
|
if (tool.clearsModalState && !modalStates.includes(tool.clearsModalState))
|
||||||
|
return errorResult(`The tool "${request.params.name}" can only be used when there is related modal state present.`, ...context.modalStatesMarkdown());
|
||||||
|
if (!tool.clearsModalState && modalStates.length)
|
||||||
|
return errorResult(`Tool "${request.params.name}" does not handle the modal state.`, ...context.modalStatesMarkdown());
|
||||||
|
|
||||||
|
try {
|
||||||
|
return await context.run(tool, request.params.arguments);
|
||||||
|
} catch (error) {
|
||||||
|
return errorResult(String(error));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return new Connection(server, context);
|
||||||
|
}
|
||||||
|
|
||||||
|
export class Connection {
|
||||||
|
readonly server: McpServer;
|
||||||
|
readonly context: Context;
|
||||||
|
|
||||||
|
constructor(server: McpServer, context: Context) {
|
||||||
|
this.server = server;
|
||||||
|
this.context = context;
|
||||||
|
this.server.oninitialized = () => {
|
||||||
|
this.context.clientVersion = this.server.getClientVersion();
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async close() {
|
||||||
|
await this.server.close();
|
||||||
|
await this.context.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
473
src/context.ts
473
src/context.ts
@@ -14,209 +14,338 @@
|
|||||||
* limitations under the License.
|
* limitations under the License.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { fork } from 'child_process';
|
import debug from 'debug';
|
||||||
import path from 'path';
|
|
||||||
|
|
||||||
import * as playwright from 'playwright';
|
import * as playwright from 'playwright';
|
||||||
import yaml from 'yaml';
|
|
||||||
|
|
||||||
export type ContextOptions = {
|
import { callOnPageNoTrace, waitForCompletion } from './tools/utils.js';
|
||||||
browserName?: 'chromium' | 'firefox' | 'webkit';
|
import { ManualPromise } from './manualPromise.js';
|
||||||
userDataDir: string;
|
import { Tab } from './tab.js';
|
||||||
launchOptions?: playwright.LaunchOptions;
|
import { outputFile } from './config.js';
|
||||||
cdpEndpoint?: string;
|
|
||||||
remoteEndpoint?: string;
|
import type { ImageContent, TextContent } from '@modelcontextprotocol/sdk/types.js';
|
||||||
|
import type { ModalState, Tool, ToolActionResult } from './tools/tool.js';
|
||||||
|
import type { FullConfig } from './config.js';
|
||||||
|
import type { BrowserContextFactory } from './browserContextFactory.js';
|
||||||
|
|
||||||
|
type PendingAction = {
|
||||||
|
dialogShown: ManualPromise<void>;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const testDebug = debug('pw:mcp:test');
|
||||||
|
|
||||||
export class Context {
|
export class Context {
|
||||||
private _options: ContextOptions;
|
readonly tools: Tool[];
|
||||||
private _browser: playwright.Browser | undefined;
|
readonly config: FullConfig;
|
||||||
private _page: playwright.Page | undefined;
|
private _browserContextPromise: Promise<{ browserContext: playwright.BrowserContext, close: () => Promise<void> }> | undefined;
|
||||||
private _console: playwright.ConsoleMessage[] = [];
|
private _browserContextFactory: BrowserContextFactory;
|
||||||
private _createPagePromise: Promise<playwright.Page> | undefined;
|
private _tabs: Tab[] = [];
|
||||||
private _fileChooser: playwright.FileChooser | undefined;
|
private _currentTab: Tab | undefined;
|
||||||
private _lastSnapshotFrames: (playwright.Page | playwright.FrameLocator)[] = [];
|
private _modalStates: (ModalState & { tab: Tab })[] = [];
|
||||||
|
private _pendingAction: PendingAction | undefined;
|
||||||
|
private _downloads: { download: playwright.Download, finished: boolean, outputFile: string }[] = [];
|
||||||
|
clientVersion: { name: string; version: string; } | undefined;
|
||||||
|
|
||||||
constructor(options: ContextOptions) {
|
constructor(tools: Tool[], config: FullConfig, browserContextFactory: BrowserContextFactory) {
|
||||||
this._options = options;
|
this.tools = tools;
|
||||||
|
this.config = config;
|
||||||
|
this._browserContextFactory = browserContextFactory;
|
||||||
|
testDebug('create context');
|
||||||
}
|
}
|
||||||
|
|
||||||
async createPage(): Promise<playwright.Page> {
|
clientSupportsImages(): boolean {
|
||||||
if (this._createPagePromise)
|
if (this.config.imageResponses === 'allow')
|
||||||
return this._createPagePromise;
|
return true;
|
||||||
this._createPagePromise = (async () => {
|
if (this.config.imageResponses === 'omit')
|
||||||
const { browser, page } = await this._createPage();
|
return false;
|
||||||
page.on('console', event => this._console.push(event));
|
return !this.clientVersion?.name.includes('cursor');
|
||||||
page.on('framenavigated', frame => {
|
|
||||||
if (!frame.parentFrame())
|
|
||||||
this._console.length = 0;
|
|
||||||
});
|
|
||||||
page.on('close', () => this._onPageClose());
|
|
||||||
page.on('filechooser', chooser => this._fileChooser = chooser);
|
|
||||||
page.setDefaultNavigationTimeout(60000);
|
|
||||||
page.setDefaultTimeout(5000);
|
|
||||||
this._page = page;
|
|
||||||
this._browser = browser;
|
|
||||||
return page;
|
|
||||||
})();
|
|
||||||
return this._createPagePromise;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private _onPageClose() {
|
modalStates(): ModalState[] {
|
||||||
const browser = this._browser;
|
return this._modalStates;
|
||||||
const page = this._page;
|
|
||||||
void page?.context()?.close().then(() => browser?.close()).catch(() => {});
|
|
||||||
|
|
||||||
this._createPagePromise = undefined;
|
|
||||||
this._browser = undefined;
|
|
||||||
this._page = undefined;
|
|
||||||
this._fileChooser = undefined;
|
|
||||||
this._console.length = 0;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async install(): Promise<string> {
|
setModalState(modalState: ModalState, inTab: Tab) {
|
||||||
const channel = this._options.launchOptions?.channel ?? this._options.browserName ?? 'chrome';
|
this._modalStates.push({ ...modalState, tab: inTab });
|
||||||
const cli = path.join(require.resolve('playwright/package.json'), '..', 'cli.js');
|
}
|
||||||
const child = fork(cli, ['install', channel], {
|
|
||||||
stdio: 'pipe',
|
clearModalState(modalState: ModalState) {
|
||||||
});
|
this._modalStates = this._modalStates.filter(state => state !== modalState);
|
||||||
const output: string[] = [];
|
}
|
||||||
child.stdout?.on('data', data => output.push(data.toString()));
|
|
||||||
child.stderr?.on('data', data => output.push(data.toString()));
|
modalStatesMarkdown(): string[] {
|
||||||
return new Promise((resolve, reject) => {
|
const result: string[] = ['### Modal state'];
|
||||||
child.on('close', code => {
|
if (this._modalStates.length === 0)
|
||||||
if (code === 0)
|
result.push('- There is no modal state present');
|
||||||
resolve(channel);
|
for (const state of this._modalStates) {
|
||||||
|
const tool = this.tools.find(tool => tool.clearsModalState === state.type);
|
||||||
|
result.push(`- [${state.description}]: can be handled by the "${tool?.schema.name}" tool`);
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
tabs(): Tab[] {
|
||||||
|
return this._tabs;
|
||||||
|
}
|
||||||
|
|
||||||
|
currentTabOrDie(): Tab {
|
||||||
|
if (!this._currentTab)
|
||||||
|
throw new Error('No current snapshot available. Capture a snapshot or navigate to a new location first.');
|
||||||
|
return this._currentTab;
|
||||||
|
}
|
||||||
|
|
||||||
|
async newTab(): Promise<Tab> {
|
||||||
|
const { browserContext } = await this._ensureBrowserContext();
|
||||||
|
const page = await browserContext.newPage();
|
||||||
|
this._currentTab = this._tabs.find(t => t.page === page)!;
|
||||||
|
return this._currentTab;
|
||||||
|
}
|
||||||
|
|
||||||
|
async selectTab(index: number) {
|
||||||
|
this._currentTab = this._tabs[index - 1];
|
||||||
|
await this._currentTab.page.bringToFront();
|
||||||
|
}
|
||||||
|
|
||||||
|
async ensureTab(): Promise<Tab> {
|
||||||
|
const { browserContext } = await this._ensureBrowserContext();
|
||||||
|
if (!this._currentTab)
|
||||||
|
await browserContext.newPage();
|
||||||
|
return this._currentTab!;
|
||||||
|
}
|
||||||
|
|
||||||
|
async listTabsMarkdown(): Promise<string> {
|
||||||
|
if (!this._tabs.length)
|
||||||
|
return '### No tabs open';
|
||||||
|
const lines: string[] = ['### Open tabs'];
|
||||||
|
for (let i = 0; i < this._tabs.length; i++) {
|
||||||
|
const tab = this._tabs[i];
|
||||||
|
const title = await tab.title();
|
||||||
|
const url = tab.page.url();
|
||||||
|
const current = tab === this._currentTab ? ' (current)' : '';
|
||||||
|
lines.push(`- ${i + 1}:${current} [${title}] (${url})`);
|
||||||
|
}
|
||||||
|
return lines.join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
async closeTab(index: number | undefined) {
|
||||||
|
const tab = index === undefined ? this._currentTab : this._tabs[index - 1];
|
||||||
|
await tab?.page.close();
|
||||||
|
return await this.listTabsMarkdown();
|
||||||
|
}
|
||||||
|
|
||||||
|
async run(tool: Tool, params: Record<string, unknown> | undefined) {
|
||||||
|
// Tab management is done outside of the action() call.
|
||||||
|
const toolResult = await tool.handle(this, tool.schema.inputSchema.parse(params || {}));
|
||||||
|
const { code, action, waitForNetwork, captureSnapshot, resultOverride } = toolResult;
|
||||||
|
const racingAction = action ? () => this._raceAgainstModalDialogs(action) : undefined;
|
||||||
|
|
||||||
|
if (resultOverride)
|
||||||
|
return resultOverride;
|
||||||
|
|
||||||
|
if (!this._currentTab) {
|
||||||
|
return {
|
||||||
|
content: [{
|
||||||
|
type: 'text',
|
||||||
|
text: 'No open pages available. Use the "browser_navigate" tool to navigate to a page first.',
|
||||||
|
}],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const tab = this.currentTabOrDie();
|
||||||
|
// TODO: race against modal dialogs to resolve clicks.
|
||||||
|
let actionResult: { content?: (ImageContent | TextContent)[] } | undefined;
|
||||||
|
try {
|
||||||
|
if (waitForNetwork)
|
||||||
|
actionResult = await waitForCompletion(this, tab, async () => racingAction?.()) ?? undefined;
|
||||||
|
else
|
||||||
|
actionResult = await racingAction?.() ?? undefined;
|
||||||
|
} finally {
|
||||||
|
if (captureSnapshot && !this._javaScriptBlocked())
|
||||||
|
await tab.captureSnapshot();
|
||||||
|
}
|
||||||
|
|
||||||
|
const result: string[] = [];
|
||||||
|
result.push(`- Ran Playwright code:
|
||||||
|
\`\`\`js
|
||||||
|
${code.join('\n')}
|
||||||
|
\`\`\`
|
||||||
|
`);
|
||||||
|
|
||||||
|
if (this.modalStates().length) {
|
||||||
|
result.push(...this.modalStatesMarkdown());
|
||||||
|
return {
|
||||||
|
content: [{
|
||||||
|
type: 'text',
|
||||||
|
text: result.join('\n'),
|
||||||
|
}],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this._downloads.length) {
|
||||||
|
result.push('', '### Downloads');
|
||||||
|
for (const entry of this._downloads) {
|
||||||
|
if (entry.finished)
|
||||||
|
result.push(`- Downloaded file ${entry.download.suggestedFilename()} to ${entry.outputFile}`);
|
||||||
else
|
else
|
||||||
reject(new Error(`Failed to install browser: ${output.join('')}`));
|
result.push(`- Downloading file ${entry.download.suggestedFilename()} ...`);
|
||||||
});
|
}
|
||||||
|
result.push('');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.tabs().length > 1)
|
||||||
|
result.push(await this.listTabsMarkdown(), '');
|
||||||
|
|
||||||
|
if (this.tabs().length > 1)
|
||||||
|
result.push('### Current tab');
|
||||||
|
|
||||||
|
result.push(
|
||||||
|
`- Page URL: ${tab.page.url()}`,
|
||||||
|
`- Page Title: ${await tab.title()}`
|
||||||
|
);
|
||||||
|
|
||||||
|
if (captureSnapshot && tab.hasSnapshot())
|
||||||
|
result.push(tab.snapshotOrDie().text());
|
||||||
|
|
||||||
|
const content = actionResult?.content ?? [];
|
||||||
|
|
||||||
|
return {
|
||||||
|
content: [
|
||||||
|
...content,
|
||||||
|
{
|
||||||
|
type: 'text',
|
||||||
|
text: result.join('\n'),
|
||||||
|
}
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async waitForTimeout(time: number) {
|
||||||
|
if (!this._currentTab || this._javaScriptBlocked()) {
|
||||||
|
await new Promise(f => setTimeout(f, time));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await callOnPageNoTrace(this._currentTab.page, page => {
|
||||||
|
return page.evaluate(() => new Promise(f => setTimeout(f, 1000)));
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
existingPage(): playwright.Page {
|
private async _raceAgainstModalDialogs(action: () => Promise<ToolActionResult>): Promise<ToolActionResult> {
|
||||||
if (!this._page)
|
this._pendingAction = {
|
||||||
throw new Error('Navigate to a location to create a page');
|
dialogShown: new ManualPromise(),
|
||||||
return this._page;
|
};
|
||||||
|
|
||||||
|
let result: ToolActionResult | undefined;
|
||||||
|
try {
|
||||||
|
await Promise.race([
|
||||||
|
action().then(r => result = r),
|
||||||
|
this._pendingAction.dialogShown,
|
||||||
|
]);
|
||||||
|
} finally {
|
||||||
|
this._pendingAction = undefined;
|
||||||
|
}
|
||||||
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
async console(): Promise<playwright.ConsoleMessage[]> {
|
private _javaScriptBlocked(): boolean {
|
||||||
return this._console;
|
return this._modalStates.some(state => state.type === 'dialog');
|
||||||
|
}
|
||||||
|
|
||||||
|
dialogShown(tab: Tab, dialog: playwright.Dialog) {
|
||||||
|
this.setModalState({
|
||||||
|
type: 'dialog',
|
||||||
|
description: `"${dialog.type()}" dialog with message "${dialog.message()}"`,
|
||||||
|
dialog,
|
||||||
|
}, tab);
|
||||||
|
this._pendingAction?.dialogShown.resolve();
|
||||||
|
}
|
||||||
|
|
||||||
|
async downloadStarted(tab: Tab, download: playwright.Download) {
|
||||||
|
const entry = {
|
||||||
|
download,
|
||||||
|
finished: false,
|
||||||
|
outputFile: await outputFile(this.config, download.suggestedFilename())
|
||||||
|
};
|
||||||
|
this._downloads.push(entry);
|
||||||
|
await download.saveAs(entry.outputFile);
|
||||||
|
entry.finished = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private _onPageCreated(page: playwright.Page) {
|
||||||
|
const tab = new Tab(this, page, tab => this._onPageClosed(tab));
|
||||||
|
this._tabs.push(tab);
|
||||||
|
if (!this._currentTab)
|
||||||
|
this._currentTab = tab;
|
||||||
|
}
|
||||||
|
|
||||||
|
private _onPageClosed(tab: Tab) {
|
||||||
|
this._modalStates = this._modalStates.filter(state => state.tab !== tab);
|
||||||
|
const index = this._tabs.indexOf(tab);
|
||||||
|
if (index === -1)
|
||||||
|
return;
|
||||||
|
this._tabs.splice(index, 1);
|
||||||
|
|
||||||
|
if (this._currentTab === tab)
|
||||||
|
this._currentTab = this._tabs[Math.min(index, this._tabs.length - 1)];
|
||||||
|
if (!this._tabs.length)
|
||||||
|
void this.close();
|
||||||
}
|
}
|
||||||
|
|
||||||
async close() {
|
async close() {
|
||||||
if (!this._page)
|
if (!this._browserContextPromise)
|
||||||
return;
|
return;
|
||||||
await this._page.close();
|
|
||||||
|
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 submitFileChooser(paths: string[]) {
|
private async _setupRequestInterception(context: playwright.BrowserContext) {
|
||||||
if (!this._fileChooser)
|
if (this.config.network?.allowedOrigins?.length) {
|
||||||
throw new Error('No file chooser visible');
|
await context.route('**', route => route.abort('blockedbyclient'));
|
||||||
await this._fileChooser.setFiles(paths);
|
|
||||||
this._fileChooser = undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
hasFileChooser() {
|
for (const origin of this.config.network.allowedOrigins)
|
||||||
return !!this._fileChooser;
|
await context.route(`*://${origin}/**`, route => route.continue());
|
||||||
}
|
|
||||||
|
|
||||||
clearFileChooser() {
|
|
||||||
this._fileChooser = undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
private async _createPage(): Promise<{ browser?: playwright.Browser, page: playwright.Page }> {
|
|
||||||
if (this._options.remoteEndpoint) {
|
|
||||||
const url = new URL(this._options.remoteEndpoint);
|
|
||||||
if (this._options.browserName)
|
|
||||||
url.searchParams.set('browser', this._options.browserName);
|
|
||||||
if (this._options.launchOptions)
|
|
||||||
url.searchParams.set('launch-options', JSON.stringify(this._options.launchOptions));
|
|
||||||
const browser = await playwright[this._options.browserName ?? 'chromium'].connect(String(url));
|
|
||||||
const page = await browser.newPage();
|
|
||||||
return { browser, page };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (this._options.cdpEndpoint) {
|
if (this.config.network?.blockedOrigins?.length) {
|
||||||
const browser = await playwright.chromium.connectOverCDP(this._options.cdpEndpoint);
|
for (const origin of this.config.network.blockedOrigins)
|
||||||
const browserContext = browser.contexts()[0];
|
await context.route(`*://${origin}/**`, route => route.abort('blockedbyclient'));
|
||||||
let [page] = browserContext.pages();
|
|
||||||
if (!page)
|
|
||||||
page = await browserContext.newPage();
|
|
||||||
return { browser, page };
|
|
||||||
}
|
|
||||||
|
|
||||||
const context = await this._launchPersistentContext();
|
|
||||||
const [page] = context.pages();
|
|
||||||
return { page };
|
|
||||||
}
|
|
||||||
|
|
||||||
private async _launchPersistentContext(): Promise<playwright.BrowserContext> {
|
|
||||||
try {
|
|
||||||
const browserType = this._options.browserName ? playwright[this._options.browserName] : playwright.chromium;
|
|
||||||
return await browserType.launchPersistentContext(this._options.userDataDir, this._options.launchOptions);
|
|
||||||
} catch (error: any) {
|
|
||||||
if (error.message.includes('Executable doesn\'t exist'))
|
|
||||||
throw new Error(`Browser specified in your config is not installed. Either install it (likely) or change the config.`);
|
|
||||||
throw error;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async allFramesSnapshot() {
|
private _ensureBrowserContext() {
|
||||||
this._lastSnapshotFrames = [];
|
if (!this._browserContextPromise) {
|
||||||
const yaml = await this._allFramesSnapshot(this.existingPage());
|
this._browserContextPromise = this._setupBrowserContext();
|
||||||
return yaml.toString().trim();
|
this._browserContextPromise.catch(() => {
|
||||||
}
|
this._browserContextPromise = undefined;
|
||||||
|
});
|
||||||
private async _allFramesSnapshot(frame: playwright.Page | playwright.FrameLocator): Promise<yaml.Document> {
|
|
||||||
const frameIndex = this._lastSnapshotFrames.push(frame) - 1;
|
|
||||||
const snapshotString = await frame.locator('body').ariaSnapshot({ ref: true });
|
|
||||||
const snapshot = yaml.parseDocument(snapshotString);
|
|
||||||
|
|
||||||
const visit = async (node: any): Promise<unknown> => {
|
|
||||||
if (yaml.isPair(node)) {
|
|
||||||
await Promise.all([
|
|
||||||
visit(node.key).then(k => node.key = k),
|
|
||||||
visit(node.value).then(v => node.value = v)
|
|
||||||
]);
|
|
||||||
} else if (yaml.isSeq(node) || yaml.isMap(node)) {
|
|
||||||
node.items = await Promise.all(node.items.map(visit));
|
|
||||||
} else if (yaml.isScalar(node)) {
|
|
||||||
if (typeof node.value === 'string') {
|
|
||||||
const value = node.value;
|
|
||||||
if (frameIndex > 0)
|
|
||||||
node.value = value.replace('[ref=', `[ref=f${frameIndex}`);
|
|
||||||
if (value.startsWith('iframe ')) {
|
|
||||||
const ref = value.match(/\[ref=(.*)\]/)?.[1];
|
|
||||||
if (ref) {
|
|
||||||
try {
|
|
||||||
const childSnapshot = await this._allFramesSnapshot(frame.frameLocator(`aria-ref=${ref}`));
|
|
||||||
return snapshot.createPair(node.value, childSnapshot);
|
|
||||||
} catch (error) {
|
|
||||||
return snapshot.createPair(node.value, '<could not take iframe snapshot>');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return node;
|
|
||||||
};
|
|
||||||
await visit(snapshot.contents);
|
|
||||||
return snapshot;
|
|
||||||
}
|
|
||||||
|
|
||||||
refLocator(ref: string): playwright.Locator {
|
|
||||||
let frame = this._lastSnapshotFrames[0];
|
|
||||||
const match = ref.match(/^f(\d+)(.*)/);
|
|
||||||
if (match) {
|
|
||||||
const frameIndex = parseInt(match[1], 10);
|
|
||||||
frame = this._lastSnapshotFrames[frameIndex];
|
|
||||||
ref = match[2];
|
|
||||||
}
|
}
|
||||||
|
return this._browserContextPromise;
|
||||||
|
}
|
||||||
|
|
||||||
if (!frame)
|
private async _setupBrowserContext(): Promise<{ browserContext: playwright.BrowserContext, close: () => Promise<void> }> {
|
||||||
throw new Error(`Frame does not exist. Provide ref from the most current snapshot.`);
|
// TODO: move to the browser context factory to make it based on isolation mode.
|
||||||
|
const result = await this._browserContextFactory.createContext();
|
||||||
return frame.locator(`aria-ref=${ref}`);
|
const { browserContext } = result;
|
||||||
|
await this._setupRequestInterception(browserContext);
|
||||||
|
for (const page of browserContext.pages())
|
||||||
|
this._onPageCreated(page);
|
||||||
|
browserContext.on('page', page => this._onPageCreated(page));
|
||||||
|
if (this.config.saveTrace) {
|
||||||
|
await browserContext.tracing.start({
|
||||||
|
name: 'trace',
|
||||||
|
screenshots: false,
|
||||||
|
snapshots: true,
|
||||||
|
sources: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return result;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
37
src/fileUtils.ts
Normal file
37
src/fileUtils.ts
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
/**
|
||||||
|
* Copyright (c) Microsoft Corporation.
|
||||||
|
*
|
||||||
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
* you may not use this file except in compliance with the License.
|
||||||
|
* You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing, software
|
||||||
|
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
* See the License for the specific language governing permissions and
|
||||||
|
* limitations under the License.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import os from 'node:os';
|
||||||
|
import path from 'node:path';
|
||||||
|
|
||||||
|
import type { FullConfig } from './config.js';
|
||||||
|
|
||||||
|
export function cacheDir() {
|
||||||
|
let cacheDirectory: string;
|
||||||
|
if (process.platform === 'linux')
|
||||||
|
cacheDirectory = process.env.XDG_CACHE_HOME || path.join(os.homedir(), '.cache');
|
||||||
|
else if (process.platform === 'darwin')
|
||||||
|
cacheDirectory = path.join(os.homedir(), 'Library', 'Caches');
|
||||||
|
else if (process.platform === 'win32')
|
||||||
|
cacheDirectory = process.env.LOCALAPPDATA || path.join(os.homedir(), 'AppData', 'Local');
|
||||||
|
else
|
||||||
|
throw new Error('Unsupported platform: ' + process.platform);
|
||||||
|
return path.join(cacheDirectory, 'ms-playwright');
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function userDataDir(browserConfig: FullConfig['browser']) {
|
||||||
|
return path.join(cacheDir(), 'ms-playwright', `mcp-${browserConfig.launchOptions?.channel ?? browserConfig?.browserName}-profile`);
|
||||||
|
}
|
||||||
232
src/httpServer.ts
Normal file
232
src/httpServer.ts
Normal file
@@ -0,0 +1,232 @@
|
|||||||
|
/**
|
||||||
|
* Copyright (c) Microsoft Corporation.
|
||||||
|
*
|
||||||
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
* you may not use this file except in compliance with the License.
|
||||||
|
* You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing, software
|
||||||
|
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
* See the License for the specific language governing permissions and
|
||||||
|
* limitations under the License.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import fs from 'fs';
|
||||||
|
import path from 'path';
|
||||||
|
import http from 'http';
|
||||||
|
import net from 'net';
|
||||||
|
|
||||||
|
import mime from 'mime';
|
||||||
|
|
||||||
|
import { ManualPromise } from './manualPromise.js';
|
||||||
|
|
||||||
|
|
||||||
|
export type ServerRouteHandler = (request: http.IncomingMessage, response: http.ServerResponse) => void;
|
||||||
|
|
||||||
|
export type Transport = {
|
||||||
|
sendEvent?: (method: string, params: any) => void;
|
||||||
|
close?: () => void;
|
||||||
|
onconnect: () => void;
|
||||||
|
dispatch: (method: string, params: any) => Promise<any>;
|
||||||
|
onclose: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export class HttpServer {
|
||||||
|
private _server: http.Server;
|
||||||
|
private _urlPrefixPrecise: string = '';
|
||||||
|
private _urlPrefixHumanReadable: string = '';
|
||||||
|
private _port: number = 0;
|
||||||
|
private _routes: { prefix?: string, exact?: string, handler: ServerRouteHandler }[] = [];
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
this._server = http.createServer(this._onRequest.bind(this));
|
||||||
|
decorateServer(this._server);
|
||||||
|
}
|
||||||
|
|
||||||
|
server() {
|
||||||
|
return this._server;
|
||||||
|
}
|
||||||
|
|
||||||
|
routePrefix(prefix: string, handler: ServerRouteHandler) {
|
||||||
|
this._routes.push({ prefix, handler });
|
||||||
|
}
|
||||||
|
|
||||||
|
routePath(path: string, handler: ServerRouteHandler) {
|
||||||
|
this._routes.push({ exact: path, handler });
|
||||||
|
}
|
||||||
|
|
||||||
|
port(): number {
|
||||||
|
return this._port;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async _tryStart(port: number | undefined, host: string) {
|
||||||
|
const errorPromise = new ManualPromise();
|
||||||
|
const errorListener = (error: Error) => errorPromise.reject(error);
|
||||||
|
this._server.on('error', errorListener);
|
||||||
|
|
||||||
|
try {
|
||||||
|
this._server.listen(port, host);
|
||||||
|
await Promise.race([
|
||||||
|
new Promise(cb => this._server!.once('listening', cb)),
|
||||||
|
errorPromise,
|
||||||
|
]);
|
||||||
|
} finally {
|
||||||
|
this._server.removeListener('error', errorListener);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async start(options: { port?: number, preferredPort?: number, host?: string } = {}): Promise<void> {
|
||||||
|
const host = options.host || 'localhost';
|
||||||
|
if (options.preferredPort) {
|
||||||
|
try {
|
||||||
|
await this._tryStart(options.preferredPort, host);
|
||||||
|
} catch (e: any) {
|
||||||
|
if (!e || !e.message || !e.message.includes('EADDRINUSE'))
|
||||||
|
throw e;
|
||||||
|
await this._tryStart(undefined, host);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
await this._tryStart(options.port, host);
|
||||||
|
}
|
||||||
|
|
||||||
|
const address = this._server.address();
|
||||||
|
if (typeof address === 'string') {
|
||||||
|
this._urlPrefixPrecise = address;
|
||||||
|
this._urlPrefixHumanReadable = address;
|
||||||
|
} else {
|
||||||
|
this._port = address!.port;
|
||||||
|
const resolvedHost = address!.family === 'IPv4' ? address!.address : `[${address!.address}]`;
|
||||||
|
this._urlPrefixPrecise = `http://${resolvedHost}:${address!.port}`;
|
||||||
|
this._urlPrefixHumanReadable = `http://${host}:${address!.port}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async stop() {
|
||||||
|
await new Promise(cb => this._server!.close(cb));
|
||||||
|
}
|
||||||
|
|
||||||
|
urlPrefix(purpose: 'human-readable' | 'precise'): string {
|
||||||
|
return purpose === 'human-readable' ? this._urlPrefixHumanReadable : this._urlPrefixPrecise;
|
||||||
|
}
|
||||||
|
|
||||||
|
serveFile(request: http.IncomingMessage, response: http.ServerResponse, absoluteFilePath: string, headers?: { [name: string]: string }): boolean {
|
||||||
|
try {
|
||||||
|
for (const [name, value] of Object.entries(headers || {}))
|
||||||
|
response.setHeader(name, value);
|
||||||
|
if (request.headers.range)
|
||||||
|
this._serveRangeFile(request, response, absoluteFilePath);
|
||||||
|
else
|
||||||
|
this._serveFile(response, absoluteFilePath);
|
||||||
|
return true;
|
||||||
|
} catch (e) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_serveFile(response: http.ServerResponse, absoluteFilePath: string) {
|
||||||
|
const content = fs.readFileSync(absoluteFilePath);
|
||||||
|
response.statusCode = 200;
|
||||||
|
const contentType = mime.getType(path.extname(absoluteFilePath)) || 'application/octet-stream';
|
||||||
|
response.setHeader('Content-Type', contentType);
|
||||||
|
response.setHeader('Content-Length', content.byteLength);
|
||||||
|
response.end(content);
|
||||||
|
}
|
||||||
|
|
||||||
|
_serveRangeFile(request: http.IncomingMessage, response: http.ServerResponse, absoluteFilePath: string) {
|
||||||
|
const range = request.headers.range;
|
||||||
|
if (!range || !range.startsWith('bytes=') || range.includes(', ') || [...range].filter(char => char === '-').length !== 1) {
|
||||||
|
response.statusCode = 400;
|
||||||
|
return response.end('Bad request');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse the range header: https://datatracker.ietf.org/doc/html/rfc7233#section-2.1
|
||||||
|
const [startStr, endStr] = range.replace(/bytes=/, '').split('-');
|
||||||
|
|
||||||
|
// Both start and end (when passing to fs.createReadStream) and the range header are inclusive and start counting at 0.
|
||||||
|
let start: number;
|
||||||
|
let end: number;
|
||||||
|
const size = fs.statSync(absoluteFilePath).size;
|
||||||
|
if (startStr !== '' && endStr === '') {
|
||||||
|
// No end specified: use the whole file
|
||||||
|
start = +startStr;
|
||||||
|
end = size - 1;
|
||||||
|
} else if (startStr === '' && endStr !== '') {
|
||||||
|
// No start specified: calculate start manually
|
||||||
|
start = size - +endStr;
|
||||||
|
end = size - 1;
|
||||||
|
} else {
|
||||||
|
start = +startStr;
|
||||||
|
end = +endStr;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle unavailable range request
|
||||||
|
if (Number.isNaN(start) || Number.isNaN(end) || start >= size || end >= size || start > end) {
|
||||||
|
// Return the 416 Range Not Satisfiable: https://datatracker.ietf.org/doc/html/rfc7233#section-4.4
|
||||||
|
response.writeHead(416, {
|
||||||
|
'Content-Range': `bytes */${size}`
|
||||||
|
});
|
||||||
|
return response.end();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sending Partial Content: https://datatracker.ietf.org/doc/html/rfc7233#section-4.1
|
||||||
|
response.writeHead(206, {
|
||||||
|
'Content-Range': `bytes ${start}-${end}/${size}`,
|
||||||
|
'Accept-Ranges': 'bytes',
|
||||||
|
'Content-Length': end - start + 1,
|
||||||
|
'Content-Type': mime.getType(path.extname(absoluteFilePath))!,
|
||||||
|
});
|
||||||
|
|
||||||
|
const readable = fs.createReadStream(absoluteFilePath, { start, end });
|
||||||
|
readable.pipe(response);
|
||||||
|
}
|
||||||
|
|
||||||
|
private _onRequest(request: http.IncomingMessage, response: http.ServerResponse) {
|
||||||
|
if (request.method === 'OPTIONS') {
|
||||||
|
response.writeHead(200);
|
||||||
|
response.end();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
request.on('error', () => response.end());
|
||||||
|
try {
|
||||||
|
if (!request.url) {
|
||||||
|
response.end();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const url = new URL('http://localhost' + request.url);
|
||||||
|
for (const route of this._routes) {
|
||||||
|
if (route.exact && url.pathname === route.exact) {
|
||||||
|
route.handler(request, response);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (route.prefix && url.pathname.startsWith(route.prefix)) {
|
||||||
|
route.handler(request, response);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
response.statusCode = 404;
|
||||||
|
response.end();
|
||||||
|
} catch (e) {
|
||||||
|
response.end();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function decorateServer(server: net.Server) {
|
||||||
|
const sockets = new Set<net.Socket>();
|
||||||
|
server.on('connection', socket => {
|
||||||
|
sockets.add(socket);
|
||||||
|
socket.once('close', () => sockets.delete(socket));
|
||||||
|
});
|
||||||
|
|
||||||
|
const close = server.close;
|
||||||
|
server.close = (callback?: (err?: Error) => void) => {
|
||||||
|
for (const socket of sockets)
|
||||||
|
socket.destroy();
|
||||||
|
sockets.clear();
|
||||||
|
return close.call(server, callback);
|
||||||
|
};
|
||||||
|
}
|
||||||
97
src/index.ts
97
src/index.ts
@@ -14,76 +14,33 @@
|
|||||||
* limitations under the License.
|
* limitations under the License.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { createServerWithTools } from './server';
|
import { createConnection as createConnectionImpl } from './connection.js';
|
||||||
import * as snapshot from './tools/snapshot';
|
import type { Connection } from '../index.js';
|
||||||
import * as common from './tools/common';
|
import { resolveConfig } from './config.js';
|
||||||
import * as screenshot from './tools/screenshot';
|
import { contextFactory } from './browserContextFactory.js';
|
||||||
import { console } from './resources/console';
|
|
||||||
|
|
||||||
import type { Tool } from './tools/tool';
|
import type { Config } from '../config.js';
|
||||||
import type { Resource } from './resources/resource';
|
import type { BrowserContext } from 'playwright';
|
||||||
import type { Server } from '@modelcontextprotocol/sdk/server/index.js';
|
import type { BrowserContextFactory } from './browserContextFactory.js';
|
||||||
import type { LaunchOptions } from 'playwright';
|
|
||||||
|
|
||||||
const commonTools: Tool[] = [
|
export async function createConnection(userConfig: Config = {}, contextGetter?: () => Promise<BrowserContext>): Promise<Connection> {
|
||||||
common.pressKey,
|
const config = await resolveConfig(userConfig);
|
||||||
common.wait,
|
const factory = contextGetter ? new SimpleBrowserContextFactory(contextGetter) : contextFactory(config.browser);
|
||||||
common.pdf,
|
return createConnectionImpl(config, factory);
|
||||||
common.close,
|
}
|
||||||
common.install,
|
|
||||||
];
|
class SimpleBrowserContextFactory implements BrowserContextFactory {
|
||||||
|
private readonly _contextGetter: () => Promise<BrowserContext>;
|
||||||
const snapshotTools: Tool[] = [
|
|
||||||
common.navigate(true),
|
constructor(contextGetter: () => Promise<BrowserContext>) {
|
||||||
common.goBack(true),
|
this._contextGetter = contextGetter;
|
||||||
common.goForward(true),
|
}
|
||||||
common.chooseFile(true),
|
|
||||||
snapshot.snapshot,
|
async createContext(): Promise<{ browserContext: BrowserContext, close: () => Promise<void> }> {
|
||||||
snapshot.click,
|
const browserContext = await this._contextGetter();
|
||||||
snapshot.hover,
|
return {
|
||||||
snapshot.type,
|
browserContext,
|
||||||
snapshot.selectOption,
|
close: () => browserContext.close()
|
||||||
snapshot.screenshot,
|
};
|
||||||
...commonTools,
|
}
|
||||||
];
|
|
||||||
|
|
||||||
const screenshotTools: Tool[] = [
|
|
||||||
common.navigate(false),
|
|
||||||
common.goBack(false),
|
|
||||||
common.goForward(false),
|
|
||||||
common.chooseFile(false),
|
|
||||||
screenshot.screenshot,
|
|
||||||
screenshot.moveMouse,
|
|
||||||
screenshot.click,
|
|
||||||
screenshot.drag,
|
|
||||||
screenshot.type,
|
|
||||||
...commonTools,
|
|
||||||
];
|
|
||||||
|
|
||||||
const resources: Resource[] = [
|
|
||||||
console,
|
|
||||||
];
|
|
||||||
|
|
||||||
type Options = {
|
|
||||||
browserName?: 'chromium' | 'firefox' | 'webkit';
|
|
||||||
userDataDir?: string;
|
|
||||||
launchOptions?: LaunchOptions;
|
|
||||||
cdpEndpoint?: string;
|
|
||||||
vision?: boolean;
|
|
||||||
};
|
|
||||||
|
|
||||||
const packageJSON = require('../package.json');
|
|
||||||
|
|
||||||
export function createServer(options?: Options): Server {
|
|
||||||
const tools = options?.vision ? screenshotTools : snapshotTools;
|
|
||||||
return createServerWithTools({
|
|
||||||
name: 'Playwright',
|
|
||||||
version: packageJSON.version,
|
|
||||||
tools,
|
|
||||||
resources,
|
|
||||||
browserName: options?.browserName,
|
|
||||||
userDataDir: options?.userDataDir ?? '',
|
|
||||||
launchOptions: options?.launchOptions,
|
|
||||||
cdpEndpoint: options?.cdpEndpoint,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|||||||
53
src/javascript.ts
Normal file
53
src/javascript.ts
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
/**
|
||||||
|
* Copyright (c) Microsoft Corporation.
|
||||||
|
*
|
||||||
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
* you may not use this file except in compliance with the License.
|
||||||
|
* You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing, software
|
||||||
|
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
* See the License for the specific language governing permissions and
|
||||||
|
* limitations under the License.
|
||||||
|
*/
|
||||||
|
|
||||||
|
// adapted from:
|
||||||
|
// - https://github.com/microsoft/playwright/blob/76ee48dc9d4034536e3ec5b2c7ce8be3b79418a8/packages/playwright-core/src/utils/isomorphic/stringUtils.ts
|
||||||
|
// - https://github.com/microsoft/playwright/blob/76ee48dc9d4034536e3ec5b2c7ce8be3b79418a8/packages/playwright-core/src/server/codegen/javascript.ts
|
||||||
|
|
||||||
|
// NOTE: this function should not be used to escape any selectors.
|
||||||
|
export function escapeWithQuotes(text: string, char: string = '\'') {
|
||||||
|
const stringified = JSON.stringify(text);
|
||||||
|
const escapedText = stringified.substring(1, stringified.length - 1).replace(/\\"/g, '"');
|
||||||
|
if (char === '\'')
|
||||||
|
return char + escapedText.replace(/[']/g, '\\\'') + char;
|
||||||
|
if (char === '"')
|
||||||
|
return char + escapedText.replace(/["]/g, '\\"') + char;
|
||||||
|
if (char === '`')
|
||||||
|
return char + escapedText.replace(/[`]/g, '`') + char;
|
||||||
|
throw new Error('Invalid escape char');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function quote(text: string) {
|
||||||
|
return escapeWithQuotes(text, '\'');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatObject(value: any, indent = ' '): string {
|
||||||
|
if (typeof value === 'string')
|
||||||
|
return quote(value);
|
||||||
|
if (Array.isArray(value))
|
||||||
|
return `[${value.map(o => formatObject(o)).join(', ')}]`;
|
||||||
|
if (typeof value === 'object') {
|
||||||
|
const keys = Object.keys(value).filter(key => value[key] !== undefined).sort();
|
||||||
|
if (!keys.length)
|
||||||
|
return '{}';
|
||||||
|
const tokens: string[] = [];
|
||||||
|
for (const key of keys)
|
||||||
|
tokens.push(`${key}: ${formatObject(value[key])}`);
|
||||||
|
return `{\n${indent}${tokens.join(`,\n${indent}`)}\n}`;
|
||||||
|
}
|
||||||
|
return String(value);
|
||||||
|
}
|
||||||
127
src/manualPromise.ts
Normal file
127
src/manualPromise.ts
Normal file
@@ -0,0 +1,127 @@
|
|||||||
|
/**
|
||||||
|
* Copyright (c) Microsoft Corporation.
|
||||||
|
*
|
||||||
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
* you may not use this file except in compliance with the License.
|
||||||
|
* You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing, software
|
||||||
|
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
* See the License for the specific language governing permissions and
|
||||||
|
* limitations under the License.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export class ManualPromise<T = void> extends Promise<T> {
|
||||||
|
private _resolve!: (t: T) => void;
|
||||||
|
private _reject!: (e: Error) => void;
|
||||||
|
private _isDone: boolean;
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
let resolve: (t: T) => void;
|
||||||
|
let reject: (e: Error) => void;
|
||||||
|
super((f, r) => {
|
||||||
|
resolve = f;
|
||||||
|
reject = r;
|
||||||
|
});
|
||||||
|
this._isDone = false;
|
||||||
|
this._resolve = resolve!;
|
||||||
|
this._reject = reject!;
|
||||||
|
}
|
||||||
|
|
||||||
|
isDone() {
|
||||||
|
return this._isDone;
|
||||||
|
}
|
||||||
|
|
||||||
|
resolve(t: T) {
|
||||||
|
this._isDone = true;
|
||||||
|
this._resolve(t);
|
||||||
|
}
|
||||||
|
|
||||||
|
reject(e: Error) {
|
||||||
|
this._isDone = true;
|
||||||
|
this._reject(e);
|
||||||
|
}
|
||||||
|
|
||||||
|
static override get [Symbol.species]() {
|
||||||
|
return Promise;
|
||||||
|
}
|
||||||
|
|
||||||
|
override get [Symbol.toStringTag]() {
|
||||||
|
return 'ManualPromise';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class LongStandingScope {
|
||||||
|
private _terminateError: Error | undefined;
|
||||||
|
private _closeError: Error | undefined;
|
||||||
|
private _terminatePromises = new Map<ManualPromise<Error>, string[]>();
|
||||||
|
private _isClosed = false;
|
||||||
|
|
||||||
|
reject(error: Error) {
|
||||||
|
this._isClosed = true;
|
||||||
|
this._terminateError = error;
|
||||||
|
for (const p of this._terminatePromises.keys())
|
||||||
|
p.resolve(error);
|
||||||
|
}
|
||||||
|
|
||||||
|
close(error: Error) {
|
||||||
|
this._isClosed = true;
|
||||||
|
this._closeError = error;
|
||||||
|
for (const [p, frames] of this._terminatePromises)
|
||||||
|
p.resolve(cloneError(error, frames));
|
||||||
|
}
|
||||||
|
|
||||||
|
isClosed() {
|
||||||
|
return this._isClosed;
|
||||||
|
}
|
||||||
|
|
||||||
|
static async raceMultiple<T>(scopes: LongStandingScope[], promise: Promise<T>): Promise<T> {
|
||||||
|
return Promise.race(scopes.map(s => s.race(promise)));
|
||||||
|
}
|
||||||
|
|
||||||
|
async race<T>(promise: Promise<T> | Promise<T>[]): Promise<T> {
|
||||||
|
return this._race(Array.isArray(promise) ? promise : [promise], false) as Promise<T>;
|
||||||
|
}
|
||||||
|
|
||||||
|
async safeRace<T>(promise: Promise<T>, defaultValue?: T): Promise<T> {
|
||||||
|
return this._race([promise], true, defaultValue);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async _race(promises: Promise<any>[], safe: boolean, defaultValue?: any): Promise<any> {
|
||||||
|
const terminatePromise = new ManualPromise<Error>();
|
||||||
|
const frames = captureRawStack();
|
||||||
|
if (this._terminateError)
|
||||||
|
terminatePromise.resolve(this._terminateError);
|
||||||
|
if (this._closeError)
|
||||||
|
terminatePromise.resolve(cloneError(this._closeError, frames));
|
||||||
|
this._terminatePromises.set(terminatePromise, frames);
|
||||||
|
try {
|
||||||
|
return await Promise.race([
|
||||||
|
terminatePromise.then(e => safe ? defaultValue : Promise.reject(e)),
|
||||||
|
...promises
|
||||||
|
]);
|
||||||
|
} finally {
|
||||||
|
this._terminatePromises.delete(terminatePromise);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function cloneError(error: Error, frames: string[]) {
|
||||||
|
const clone = new Error();
|
||||||
|
clone.name = error.name;
|
||||||
|
clone.message = error.message;
|
||||||
|
clone.stack = [error.name + ':' + error.message, ...frames].join('\n');
|
||||||
|
return clone;
|
||||||
|
}
|
||||||
|
|
||||||
|
function captureRawStack(): string[] {
|
||||||
|
const stackTraceLimit = Error.stackTraceLimit;
|
||||||
|
Error.stackTraceLimit = 50;
|
||||||
|
const error = new Error();
|
||||||
|
const stack = error.stack || '';
|
||||||
|
Error.stackTraceLimit = stackTraceLimit;
|
||||||
|
return stack.split('\n');
|
||||||
|
}
|
||||||
@@ -14,23 +14,9 @@
|
|||||||
* limitations under the License.
|
* limitations under the License.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import type { Context } from '../context';
|
import fs from 'node:fs';
|
||||||
|
import url from 'node:url';
|
||||||
|
import path from 'node:path';
|
||||||
|
|
||||||
export type ResourceSchema = {
|
const __filename = url.fileURLToPath(import.meta.url);
|
||||||
uri: string;
|
export const packageJSON = JSON.parse(fs.readFileSync(path.join(path.dirname(__filename), '..', 'package.json'), 'utf8'));
|
||||||
name: string;
|
|
||||||
description?: string;
|
|
||||||
mimeType?: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type ResourceResult = {
|
|
||||||
uri: string;
|
|
||||||
mimeType?: string;
|
|
||||||
text?: string;
|
|
||||||
blob?: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type Resource = {
|
|
||||||
schema: ResourceSchema;
|
|
||||||
read: (context: Context, uri: string) => Promise<ResourceResult[]>;
|
|
||||||
};
|
|
||||||
55
src/pageSnapshot.ts
Normal file
55
src/pageSnapshot.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 * as playwright from 'playwright';
|
||||||
|
import { callOnPageNoTrace } from './tools/utils.js';
|
||||||
|
|
||||||
|
type PageEx = playwright.Page & {
|
||||||
|
_snapshotForAI: () => Promise<string>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export class PageSnapshot {
|
||||||
|
private _page: playwright.Page;
|
||||||
|
private _text!: string;
|
||||||
|
|
||||||
|
constructor(page: playwright.Page) {
|
||||||
|
this._page = page;
|
||||||
|
}
|
||||||
|
|
||||||
|
static async create(page: playwright.Page): Promise<PageSnapshot> {
|
||||||
|
const snapshot = new PageSnapshot(page);
|
||||||
|
await snapshot._build();
|
||||||
|
return snapshot;
|
||||||
|
}
|
||||||
|
|
||||||
|
text(): string {
|
||||||
|
return this._text;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async _build() {
|
||||||
|
const snapshot = await callOnPageNoTrace(this._page, page => (page as PageEx)._snapshotForAI());
|
||||||
|
this._text = [
|
||||||
|
`- Page Snapshot`,
|
||||||
|
'```yaml',
|
||||||
|
snapshot,
|
||||||
|
'```',
|
||||||
|
].join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
refLocator(params: { element: string, ref: string }): playwright.Locator {
|
||||||
|
return this._page.locator(`aria-ref=${params.ref}`).describe(params.element);
|
||||||
|
}
|
||||||
|
}
|
||||||
197
src/program.ts
197
src/program.ts
@@ -14,170 +14,67 @@
|
|||||||
* limitations under the License.
|
* limitations under the License.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import http from 'http';
|
|
||||||
import fs from 'fs';
|
|
||||||
import os from 'os';
|
|
||||||
import path from 'path';
|
|
||||||
|
|
||||||
import { program } from 'commander';
|
import { program } from 'commander';
|
||||||
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
// @ts-ignore
|
||||||
import { SSEServerTransport } from '@modelcontextprotocol/sdk/server/sse.js';
|
import { startTraceViewerServer } from 'playwright-core/lib/server';
|
||||||
|
|
||||||
|
import { startHttpServer, startHttpTransport, startStdioTransport } from './transport.js';
|
||||||
import { createServer } from './index';
|
import { resolveCLIConfig } from './config.js';
|
||||||
import { ServerList } from './server';
|
import { Server } from './server.js';
|
||||||
|
import { packageJSON } from './package.js';
|
||||||
import type { LaunchOptions } from 'playwright';
|
|
||||||
import assert from 'assert';
|
|
||||||
|
|
||||||
const packageJSON = require('../package.json');
|
|
||||||
|
|
||||||
program
|
program
|
||||||
.version('Version ' + packageJSON.version)
|
.version('Version ' + packageJSON.version)
|
||||||
.name(packageJSON.name)
|
.name(packageJSON.name)
|
||||||
.option('--browser <browser>', 'Browser or chrome channel to use, possible values: chrome, firefox, webkit, msedge.')
|
.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('--browser-agent <endpoint>', 'Use browser agent (experimental).')
|
||||||
|
.option('--caps <caps>', 'comma-separated list of capabilities to enable, possible values: tabs, pdf, history, wait, files, install. Default is all.')
|
||||||
.option('--cdp-endpoint <endpoint>', 'CDP endpoint to connect to.')
|
.option('--cdp-endpoint <endpoint>', 'CDP endpoint to connect to.')
|
||||||
.option('--executable-path <path>', 'Path to the browser executable.')
|
.option('--config <path>', 'path to the configuration file.')
|
||||||
.option('--headless', 'Run browser in headless mode, headed by default')
|
.option('--device <device>', 'device to emulate, for example: "iPhone 15"')
|
||||||
.option('--port <port>', 'Port to listen on for SSE transport.')
|
.option('--executable-path <path>', 'path to the browser executable.')
|
||||||
.option('--user-data-dir <path>', 'Path to the user data directory')
|
.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", "omit", or "auto". Defaults to "auto", which sends images if the client can display them.')
|
||||||
|
.option('--no-sandbox', 'disable the sandbox for all process types that are normally sandboxed.')
|
||||||
|
.option('--output-dir <path>', 'path to the directory for output files.')
|
||||||
|
.option('--port <port>', 'port to listen on for SSE transport.')
|
||||||
|
.option('--proxy-bypass <bypass>', 'comma-separated domains to bypass proxy, for example ".com,chromium.org,.domain.com"')
|
||||||
|
.option('--proxy-server <proxy>', 'specify proxy server, for example "http://myproxy:3128" or "socks5://myproxy:8080"')
|
||||||
|
.option('--save-trace', 'Whether to save the Playwright Trace of the session into the output directory.')
|
||||||
|
.option('--storage-state <path>', 'path to the storage state file for isolated sessions.')
|
||||||
|
.option('--user-agent <ua string>', 'specify user agent string')
|
||||||
|
.option('--user-data-dir <path>', 'path to the user data directory. If not specified, a temporary directory will be created.')
|
||||||
|
.option('--viewport-size <size>', 'specify browser viewport size in pixels, for example "1280, 720"')
|
||||||
.option('--vision', 'Run server that uses screenshots (Aria snapshots are used by default)')
|
.option('--vision', 'Run server that uses screenshots (Aria snapshots are used by default)')
|
||||||
.action(async options => {
|
.action(async options => {
|
||||||
let browserName: 'chromium' | 'firefox' | 'webkit';
|
const config = await resolveCLIConfig(options);
|
||||||
let channel: string | undefined;
|
const httpServer = config.server.port !== undefined ? await startHttpServer(config.server) : undefined;
|
||||||
switch (options.browser) {
|
|
||||||
case 'chrome':
|
|
||||||
case 'chrome-beta':
|
|
||||||
case 'chrome-canary':
|
|
||||||
case 'chrome-dev':
|
|
||||||
case 'msedge':
|
|
||||||
case 'msedge-beta':
|
|
||||||
case 'msedge-canary':
|
|
||||||
case 'msedge-dev':
|
|
||||||
browserName = 'chromium';
|
|
||||||
channel = options.browser;
|
|
||||||
break;
|
|
||||||
case 'chromium':
|
|
||||||
browserName = 'chromium';
|
|
||||||
break;
|
|
||||||
case 'firefox':
|
|
||||||
browserName = 'firefox';
|
|
||||||
break;
|
|
||||||
case 'webkit':
|
|
||||||
browserName = 'webkit';
|
|
||||||
break;
|
|
||||||
default:
|
|
||||||
browserName = 'chromium';
|
|
||||||
channel = 'chrome';
|
|
||||||
}
|
|
||||||
|
|
||||||
const launchOptions: LaunchOptions = {
|
const server = new Server(config);
|
||||||
headless: !!options.headless,
|
server.setupExitWatchdog();
|
||||||
channel,
|
|
||||||
executablePath: options.executablePath,
|
|
||||||
};
|
|
||||||
|
|
||||||
const userDataDir = options.userDataDir ?? await createUserDataDir(browserName);
|
if (httpServer)
|
||||||
|
startHttpTransport(httpServer, server);
|
||||||
|
else
|
||||||
|
await startStdioTransport(server);
|
||||||
|
|
||||||
const serverList = new ServerList(() => createServer({
|
if (config.saveTrace) {
|
||||||
browserName,
|
const server = await startTraceViewerServer();
|
||||||
userDataDir,
|
const urlPrefix = server.urlPrefix('human-readable');
|
||||||
launchOptions,
|
const url = urlPrefix + '/trace/index.html?trace=' + config.browser.launchOptions.tracesDir + '/trace.json';
|
||||||
vision: !!options.vision,
|
// eslint-disable-next-line no-console
|
||||||
cdpEndpoint: options.cdpEndpoint,
|
console.error('\nTrace viewer listening on ' + url);
|
||||||
}));
|
|
||||||
setupExitWatchdog(serverList);
|
|
||||||
|
|
||||||
if (options.port) {
|
|
||||||
startSSEServer(+options.port, serverList);
|
|
||||||
} else {
|
|
||||||
const server = await serverList.create();
|
|
||||||
await server.connect(new StdioServerTransport());
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
function setupExitWatchdog(serverList: ServerList) {
|
function semicolonSeparatedList(value: string): string[] {
|
||||||
process.stdin.on('close', async () => {
|
return value.split(';').map(v => v.trim());
|
||||||
setTimeout(() => process.exit(0), 15000);
|
|
||||||
await serverList.closeAll();
|
|
||||||
process.exit(0);
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
program.parse(process.argv);
|
void program.parseAsync(process.argv);
|
||||||
|
|
||||||
async function createUserDataDir(browserName: 'chromium' | 'firefox' | 'webkit') {
|
|
||||||
let cacheDirectory: string;
|
|
||||||
if (process.platform === 'linux')
|
|
||||||
cacheDirectory = process.env.XDG_CACHE_HOME || path.join(os.homedir(), '.cache');
|
|
||||||
else if (process.platform === 'darwin')
|
|
||||||
cacheDirectory = path.join(os.homedir(), 'Library', 'Caches');
|
|
||||||
else if (process.platform === 'win32')
|
|
||||||
cacheDirectory = process.env.LOCALAPPDATA || path.join(os.homedir(), 'AppData', 'Local');
|
|
||||||
else
|
|
||||||
throw new Error('Unsupported platform: ' + process.platform);
|
|
||||||
const result = path.join(cacheDirectory, 'ms-playwright', `mcp-${browserName}-profile`);
|
|
||||||
await fs.promises.mkdir(result, { recursive: true });
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function startSSEServer(port: number, serverList: ServerList) {
|
|
||||||
const sessions = new Map<string, SSEServerTransport>();
|
|
||||||
const httpServer = http.createServer(async (req, res) => {
|
|
||||||
if (req.method === 'POST') {
|
|
||||||
const searchParams = new URL(`http://localhost${req.url}`).searchParams;
|
|
||||||
const sessionId = searchParams.get('sessionId');
|
|
||||||
if (!sessionId) {
|
|
||||||
res.statusCode = 400;
|
|
||||||
res.end('Missing sessionId');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const transport = sessions.get(sessionId);
|
|
||||||
if (!transport) {
|
|
||||||
res.statusCode = 404;
|
|
||||||
res.end('Session not found');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
await transport.handlePostMessage(req, res);
|
|
||||||
return;
|
|
||||||
} else if (req.method === 'GET') {
|
|
||||||
const transport = new SSEServerTransport('/sse', res);
|
|
||||||
sessions.set(transport.sessionId, transport);
|
|
||||||
const server = await serverList.create();
|
|
||||||
res.on('close', () => {
|
|
||||||
sessions.delete(transport.sessionId);
|
|
||||||
serverList.close(server).catch(e => console.error(e));
|
|
||||||
});
|
|
||||||
await server.connect(transport);
|
|
||||||
return;
|
|
||||||
} else {
|
|
||||||
res.statusCode = 405;
|
|
||||||
res.end('Method not allowed');
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
httpServer.listen(port, () => {
|
|
||||||
const address = httpServer.address();
|
|
||||||
assert(address, 'Could not bind server socket');
|
|
||||||
let url: string;
|
|
||||||
if (typeof address === 'string') {
|
|
||||||
url = address;
|
|
||||||
} else {
|
|
||||||
const resolvedPort = address.port;
|
|
||||||
let resolvedHost = address.family === 'IPv4' ? address.address : `[${address.address}]`;
|
|
||||||
if (resolvedHost === '0.0.0.0' || resolvedHost === '[::]')
|
|
||||||
resolvedHost = 'localhost';
|
|
||||||
url = `http://${resolvedHost}:${resolvedPort}`;
|
|
||||||
}
|
|
||||||
console.log(`Listening on ${url}`);
|
|
||||||
console.log('Put this in your client config:');
|
|
||||||
console.log(JSON.stringify({
|
|
||||||
'mcpServers': {
|
|
||||||
'playwright': {
|
|
||||||
'url': `${url}/sse`
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}, undefined, 2));
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|||||||
121
src/server.ts
121
src/server.ts
@@ -14,101 +14,46 @@
|
|||||||
* limitations under the License.
|
* limitations under the License.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { Server } from '@modelcontextprotocol/sdk/server/index.js';
|
import { createConnection } from './connection.js';
|
||||||
import { CallToolRequestSchema, ListResourcesRequestSchema, ListToolsRequestSchema, ReadResourceRequestSchema } from '@modelcontextprotocol/sdk/types.js';
|
import { contextFactory } from './browserContextFactory.js';
|
||||||
|
|
||||||
import { Context } from './context';
|
import type { FullConfig } from './config.js';
|
||||||
|
import type { Connection } from './connection.js';
|
||||||
|
import type { Transport } from '@modelcontextprotocol/sdk/shared/transport.js';
|
||||||
|
import type { BrowserContextFactory } from './browserContextFactory.js';
|
||||||
|
|
||||||
import type { Tool } from './tools/tool';
|
export class Server {
|
||||||
import type { Resource } from './resources/resource';
|
readonly config: FullConfig;
|
||||||
import type { ContextOptions } from './context';
|
private _connectionList: Connection[] = [];
|
||||||
|
private _browserConfig: FullConfig['browser'];
|
||||||
|
private _contextFactory: BrowserContextFactory;
|
||||||
|
|
||||||
type Options = ContextOptions & {
|
constructor(config: FullConfig) {
|
||||||
name: string;
|
this.config = config;
|
||||||
version: string;
|
this._browserConfig = config.browser;
|
||||||
tools: Tool[];
|
this._contextFactory = contextFactory(this._browserConfig);
|
||||||
resources: Resource[],
|
|
||||||
};
|
|
||||||
|
|
||||||
export function createServerWithTools(options: Options): Server {
|
|
||||||
const { name, version, tools, resources } = options;
|
|
||||||
const context = new Context(options);
|
|
||||||
const server = new Server({ name, version }, {
|
|
||||||
capabilities: {
|
|
||||||
tools: {},
|
|
||||||
resources: {},
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
server.setRequestHandler(ListToolsRequestSchema, async () => {
|
|
||||||
return { tools: tools.map(tool => tool.schema) };
|
|
||||||
});
|
|
||||||
|
|
||||||
server.setRequestHandler(ListResourcesRequestSchema, async () => {
|
|
||||||
return { resources: resources.map(resource => resource.schema) };
|
|
||||||
});
|
|
||||||
|
|
||||||
server.setRequestHandler(CallToolRequestSchema, async request => {
|
|
||||||
const tool = tools.find(tool => tool.schema.name === request.params.name);
|
|
||||||
if (!tool) {
|
|
||||||
return {
|
|
||||||
content: [{ type: 'text', text: `Tool "${request.params.name}" not found` }],
|
|
||||||
isError: true,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const result = await tool.handle(context, request.params.arguments);
|
|
||||||
return result;
|
|
||||||
} catch (error) {
|
|
||||||
return {
|
|
||||||
content: [{ type: 'text', text: String(error) }],
|
|
||||||
isError: true,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
server.setRequestHandler(ReadResourceRequestSchema, async request => {
|
|
||||||
const resource = resources.find(resource => resource.schema.uri === request.params.uri);
|
|
||||||
if (!resource)
|
|
||||||
return { contents: [] };
|
|
||||||
|
|
||||||
const contents = await resource.read(context, request.params.uri);
|
|
||||||
return { contents };
|
|
||||||
});
|
|
||||||
|
|
||||||
const oldClose = server.close.bind(server);
|
|
||||||
|
|
||||||
server.close = async () => {
|
|
||||||
await oldClose();
|
|
||||||
await context.close();
|
|
||||||
};
|
|
||||||
|
|
||||||
return server;
|
|
||||||
}
|
|
||||||
|
|
||||||
export class ServerList {
|
|
||||||
private _servers: Server[] = [];
|
|
||||||
private _serverFactory: () => Server;
|
|
||||||
|
|
||||||
constructor(serverFactory: () => Server) {
|
|
||||||
this._serverFactory = serverFactory;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async create() {
|
async createConnection(transport: Transport): Promise<Connection> {
|
||||||
const server = this._serverFactory();
|
const connection = createConnection(this.config, this._contextFactory);
|
||||||
this._servers.push(server);
|
this._connectionList.push(connection);
|
||||||
return server;
|
await connection.server.connect(transport);
|
||||||
|
return connection;
|
||||||
}
|
}
|
||||||
|
|
||||||
async close(server: Server) {
|
setupExitWatchdog() {
|
||||||
const index = this._servers.indexOf(server);
|
let isExiting = false;
|
||||||
if (index !== -1)
|
const handleExit = async () => {
|
||||||
this._servers.splice(index, 1);
|
if (isExiting)
|
||||||
await server.close();
|
return;
|
||||||
}
|
isExiting = true;
|
||||||
|
setTimeout(() => process.exit(0), 15000);
|
||||||
|
await Promise.all(this._connectionList.map(connection => connection.close()));
|
||||||
|
process.exit(0);
|
||||||
|
};
|
||||||
|
|
||||||
async closeAll() {
|
process.stdin.on('close', handleExit);
|
||||||
await Promise.all(this._servers.map(server => server.close()));
|
process.on('SIGINT', handleExit);
|
||||||
|
process.on('SIGTERM', handleExit);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
120
src/tab.ts
Normal file
120
src/tab.ts
Normal file
@@ -0,0 +1,120 @@
|
|||||||
|
/**
|
||||||
|
* Copyright (c) Microsoft Corporation.
|
||||||
|
*
|
||||||
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
* you may not use this file except in compliance with the License.
|
||||||
|
* You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing, software
|
||||||
|
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
* See the License for the specific language governing permissions and
|
||||||
|
* limitations under the License.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import * as playwright from 'playwright';
|
||||||
|
|
||||||
|
import { PageSnapshot } from './pageSnapshot.js';
|
||||||
|
|
||||||
|
import type { Context } from './context.js';
|
||||||
|
import { callOnPageNoTrace } from './tools/utils.js';
|
||||||
|
|
||||||
|
export class Tab {
|
||||||
|
readonly context: Context;
|
||||||
|
readonly page: playwright.Page;
|
||||||
|
private _consoleMessages: playwright.ConsoleMessage[] = [];
|
||||||
|
private _requests: Map<playwright.Request, playwright.Response | null> = new Map();
|
||||||
|
private _snapshot: PageSnapshot | undefined;
|
||||||
|
private _onPageClose: (tab: Tab) => void;
|
||||||
|
|
||||||
|
constructor(context: Context, page: playwright.Page, onPageClose: (tab: Tab) => void) {
|
||||||
|
this.context = context;
|
||||||
|
this.page = page;
|
||||||
|
this._onPageClose = onPageClose;
|
||||||
|
page.on('console', event => this._consoleMessages.push(event));
|
||||||
|
page.on('request', request => this._requests.set(request, null));
|
||||||
|
page.on('response', response => this._requests.set(response.request(), response));
|
||||||
|
page.on('close', () => this._onClose());
|
||||||
|
page.on('filechooser', chooser => {
|
||||||
|
this.context.setModalState({
|
||||||
|
type: 'fileChooser',
|
||||||
|
description: 'File chooser',
|
||||||
|
fileChooser: chooser,
|
||||||
|
}, this);
|
||||||
|
});
|
||||||
|
page.on('dialog', dialog => this.context.dialogShown(this, dialog));
|
||||||
|
page.on('download', download => {
|
||||||
|
void this.context.downloadStarted(this, download);
|
||||||
|
});
|
||||||
|
page.setDefaultNavigationTimeout(60000);
|
||||||
|
page.setDefaultTimeout(5000);
|
||||||
|
}
|
||||||
|
|
||||||
|
private _clearCollectedArtifacts() {
|
||||||
|
this._consoleMessages.length = 0;
|
||||||
|
this._requests.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
private _onClose() {
|
||||||
|
this._clearCollectedArtifacts();
|
||||||
|
this._onPageClose(this);
|
||||||
|
}
|
||||||
|
|
||||||
|
async title(): Promise<string> {
|
||||||
|
return await callOnPageNoTrace(this.page, page => page.title());
|
||||||
|
}
|
||||||
|
|
||||||
|
async waitForLoadState(state: 'load', options?: { timeout?: number }): Promise<void> {
|
||||||
|
await callOnPageNoTrace(this.page, page => page.waitForLoadState(state, options).catch(() => {}));
|
||||||
|
}
|
||||||
|
|
||||||
|
async navigate(url: string) {
|
||||||
|
this._clearCollectedArtifacts();
|
||||||
|
|
||||||
|
const downloadEvent = callOnPageNoTrace(this.page, page => page.waitForEvent('download').catch(() => {}));
|
||||||
|
try {
|
||||||
|
await this.page.goto(url, { waitUntil: 'domcontentloaded' });
|
||||||
|
} catch (_e: unknown) {
|
||||||
|
const e = _e as Error;
|
||||||
|
const mightBeDownload =
|
||||||
|
e.message.includes('net::ERR_ABORTED') // chromium
|
||||||
|
|| e.message.includes('Download is starting'); // firefox + webkit
|
||||||
|
if (!mightBeDownload)
|
||||||
|
throw e;
|
||||||
|
// on chromium, the download event is fired *after* page.goto rejects, so we wait a lil bit
|
||||||
|
const download = await Promise.race([
|
||||||
|
downloadEvent,
|
||||||
|
new Promise(resolve => setTimeout(resolve, 1000)),
|
||||||
|
]);
|
||||||
|
if (!download)
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cap load event to 5 seconds, the page is operational at this point.
|
||||||
|
await this.waitForLoadState('load', { timeout: 5000 });
|
||||||
|
}
|
||||||
|
|
||||||
|
hasSnapshot(): boolean {
|
||||||
|
return !!this._snapshot;
|
||||||
|
}
|
||||||
|
|
||||||
|
snapshotOrDie(): PageSnapshot {
|
||||||
|
if (!this._snapshot)
|
||||||
|
throw new Error('No snapshot available');
|
||||||
|
return this._snapshot;
|
||||||
|
}
|
||||||
|
|
||||||
|
consoleMessages(): playwright.ConsoleMessage[] {
|
||||||
|
return this._consoleMessages;
|
||||||
|
}
|
||||||
|
|
||||||
|
requests(): Map<playwright.Request, playwright.Response | null> {
|
||||||
|
return this._requests;
|
||||||
|
}
|
||||||
|
|
||||||
|
async captureSnapshot() {
|
||||||
|
this._snapshot = await PageSnapshot.create(this.page);
|
||||||
|
}
|
||||||
|
}
|
||||||
66
src/tools.ts
Normal file
66
src/tools.ts
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
/**
|
||||||
|
* Copyright (c) Microsoft Corporation.
|
||||||
|
*
|
||||||
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
* you may not use this file except in compliance with the License.
|
||||||
|
* You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing, software
|
||||||
|
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
* See the License for the specific language governing permissions and
|
||||||
|
* limitations under the License.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import common from './tools/common.js';
|
||||||
|
import console from './tools/console.js';
|
||||||
|
import dialogs from './tools/dialogs.js';
|
||||||
|
import files from './tools/files.js';
|
||||||
|
import install from './tools/install.js';
|
||||||
|
import keyboard from './tools/keyboard.js';
|
||||||
|
import navigate from './tools/navigate.js';
|
||||||
|
import network from './tools/network.js';
|
||||||
|
import pdf from './tools/pdf.js';
|
||||||
|
import snapshot from './tools/snapshot.js';
|
||||||
|
import tabs from './tools/tabs.js';
|
||||||
|
import screenshot from './tools/screenshot.js';
|
||||||
|
import testing from './tools/testing.js';
|
||||||
|
import vision from './tools/vision.js';
|
||||||
|
import wait from './tools/wait.js';
|
||||||
|
|
||||||
|
import type { Tool } from './tools/tool.js';
|
||||||
|
|
||||||
|
export const snapshotTools: Tool<any>[] = [
|
||||||
|
...common(true),
|
||||||
|
...console,
|
||||||
|
...dialogs(true),
|
||||||
|
...files(true),
|
||||||
|
...install,
|
||||||
|
...keyboard(true),
|
||||||
|
...navigate(true),
|
||||||
|
...network,
|
||||||
|
...pdf,
|
||||||
|
...screenshot,
|
||||||
|
...snapshot,
|
||||||
|
...tabs(true),
|
||||||
|
...testing,
|
||||||
|
...wait(true),
|
||||||
|
];
|
||||||
|
|
||||||
|
export const visionTools: Tool<any>[] = [
|
||||||
|
...common(false),
|
||||||
|
...console,
|
||||||
|
...dialogs(false),
|
||||||
|
...files(false),
|
||||||
|
...install,
|
||||||
|
...keyboard(false),
|
||||||
|
...navigate(false),
|
||||||
|
...network,
|
||||||
|
...pdf,
|
||||||
|
...tabs(false),
|
||||||
|
...testing,
|
||||||
|
...vision,
|
||||||
|
...wait(false),
|
||||||
|
];
|
||||||
@@ -14,180 +14,65 @@
|
|||||||
* limitations under the License.
|
* limitations under the License.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import os from 'os';
|
|
||||||
import path from 'path';
|
|
||||||
|
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
import { zodToJsonSchema } from 'zod-to-json-schema';
|
import { defineTool, type ToolFactory } from './tool.js';
|
||||||
|
|
||||||
import { captureAriaSnapshot, runAndWait, sanitizeForFilePath } from './utils';
|
const close = defineTool({
|
||||||
|
capability: 'core',
|
||||||
|
|
||||||
import type { ToolFactory, Tool } from './tool';
|
|
||||||
|
|
||||||
const navigateSchema = z.object({
|
|
||||||
url: z.string().describe('The URL to navigate to'),
|
|
||||||
});
|
|
||||||
|
|
||||||
export const navigate: ToolFactory = snapshot => ({
|
|
||||||
schema: {
|
|
||||||
name: 'browser_navigate',
|
|
||||||
description: 'Navigate to a URL',
|
|
||||||
inputSchema: zodToJsonSchema(navigateSchema),
|
|
||||||
},
|
|
||||||
handle: async (context, params) => {
|
|
||||||
const validatedParams = navigateSchema.parse(params);
|
|
||||||
const page = await context.createPage();
|
|
||||||
await page.goto(validatedParams.url, { waitUntil: 'domcontentloaded' });
|
|
||||||
// Cap load event to 5 seconds, the page is operational at this point.
|
|
||||||
await page.waitForLoadState('load', { timeout: 5000 }).catch(() => {});
|
|
||||||
if (snapshot)
|
|
||||||
return captureAriaSnapshot(context);
|
|
||||||
return {
|
|
||||||
content: [{
|
|
||||||
type: 'text',
|
|
||||||
text: `Navigated to ${validatedParams.url}`,
|
|
||||||
}],
|
|
||||||
};
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const goBackSchema = z.object({});
|
|
||||||
|
|
||||||
export const goBack: ToolFactory = snapshot => ({
|
|
||||||
schema: {
|
|
||||||
name: 'browser_go_back',
|
|
||||||
description: 'Go back to the previous page',
|
|
||||||
inputSchema: zodToJsonSchema(goBackSchema),
|
|
||||||
},
|
|
||||||
handle: async context => {
|
|
||||||
return await runAndWait(context, 'Navigated back', async page => page.goBack(), snapshot);
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const goForwardSchema = z.object({});
|
|
||||||
|
|
||||||
export const goForward: ToolFactory = snapshot => ({
|
|
||||||
schema: {
|
|
||||||
name: 'browser_go_forward',
|
|
||||||
description: 'Go forward to the next page',
|
|
||||||
inputSchema: zodToJsonSchema(goForwardSchema),
|
|
||||||
},
|
|
||||||
handle: async context => {
|
|
||||||
return await runAndWait(context, 'Navigated forward', async page => page.goForward(), snapshot);
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const waitSchema = z.object({
|
|
||||||
time: z.number().describe('The time to wait in seconds'),
|
|
||||||
});
|
|
||||||
|
|
||||||
export const wait: Tool = {
|
|
||||||
schema: {
|
|
||||||
name: 'browser_wait',
|
|
||||||
description: 'Wait for a specified time in seconds',
|
|
||||||
inputSchema: zodToJsonSchema(waitSchema),
|
|
||||||
},
|
|
||||||
handle: async (context, params) => {
|
|
||||||
const validatedParams = waitSchema.parse(params);
|
|
||||||
await new Promise(f => setTimeout(f, Math.min(10000, validatedParams.time * 1000)));
|
|
||||||
return {
|
|
||||||
content: [{
|
|
||||||
type: 'text',
|
|
||||||
text: `Waited for ${validatedParams.time} seconds`,
|
|
||||||
}],
|
|
||||||
};
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
const pressKeySchema = z.object({
|
|
||||||
key: z.string().describe('Name of the key to press or a character to generate, such as `ArrowLeft` or `a`'),
|
|
||||||
});
|
|
||||||
|
|
||||||
export const pressKey: Tool = {
|
|
||||||
schema: {
|
|
||||||
name: 'browser_press_key',
|
|
||||||
description: 'Press a key on the keyboard',
|
|
||||||
inputSchema: zodToJsonSchema(pressKeySchema),
|
|
||||||
},
|
|
||||||
handle: async (context, params) => {
|
|
||||||
const validatedParams = pressKeySchema.parse(params);
|
|
||||||
return await runAndWait(context, `Pressed key ${validatedParams.key}`, async page => {
|
|
||||||
await page.keyboard.press(validatedParams.key);
|
|
||||||
});
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
const pdfSchema = z.object({});
|
|
||||||
|
|
||||||
export const pdf: Tool = {
|
|
||||||
schema: {
|
|
||||||
name: 'browser_save_as_pdf',
|
|
||||||
description: 'Save page as PDF',
|
|
||||||
inputSchema: zodToJsonSchema(pdfSchema),
|
|
||||||
},
|
|
||||||
handle: async context => {
|
|
||||||
const page = context.existingPage();
|
|
||||||
const fileName = path.join(os.tmpdir(), sanitizeForFilePath(`page-${new Date().toISOString()}`)) + '.pdf';
|
|
||||||
await page.pdf({ path: fileName });
|
|
||||||
return {
|
|
||||||
content: [{
|
|
||||||
type: 'text',
|
|
||||||
text: `Saved as ${fileName}`,
|
|
||||||
}],
|
|
||||||
};
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
const closeSchema = z.object({});
|
|
||||||
|
|
||||||
export const close: Tool = {
|
|
||||||
schema: {
|
schema: {
|
||||||
name: 'browser_close',
|
name: 'browser_close',
|
||||||
|
title: 'Close browser',
|
||||||
description: 'Close the page',
|
description: 'Close the page',
|
||||||
inputSchema: zodToJsonSchema(closeSchema),
|
inputSchema: z.object({}),
|
||||||
|
type: 'readOnly',
|
||||||
},
|
},
|
||||||
|
|
||||||
handle: async context => {
|
handle: async context => {
|
||||||
await context.close();
|
await context.close();
|
||||||
return {
|
return {
|
||||||
content: [{
|
code: [`await page.close()`],
|
||||||
type: 'text',
|
captureSnapshot: false,
|
||||||
text: `Page closed`,
|
waitForNetwork: false,
|
||||||
}],
|
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
};
|
|
||||||
|
|
||||||
const chooseFileSchema = z.object({
|
|
||||||
paths: z.array(z.string()).describe('The absolute paths to the files to upload. Can be a single file or multiple files.'),
|
|
||||||
});
|
});
|
||||||
|
|
||||||
export const chooseFile: ToolFactory = snapshot => ({
|
const resize: ToolFactory = captureSnapshot => defineTool({
|
||||||
|
capability: 'core',
|
||||||
schema: {
|
schema: {
|
||||||
name: 'browser_choose_file',
|
name: 'browser_resize',
|
||||||
description: 'Choose one or multiple files to upload',
|
title: 'Resize browser window',
|
||||||
inputSchema: zodToJsonSchema(chooseFileSchema),
|
description: 'Resize the browser window',
|
||||||
|
inputSchema: z.object({
|
||||||
|
width: z.number().describe('Width of the browser window'),
|
||||||
|
height: z.number().describe('Height of the browser window'),
|
||||||
|
}),
|
||||||
|
type: 'readOnly',
|
||||||
},
|
},
|
||||||
|
|
||||||
handle: async (context, params) => {
|
handle: async (context, params) => {
|
||||||
const validatedParams = chooseFileSchema.parse(params);
|
const tab = context.currentTabOrDie();
|
||||||
return await runAndWait(context, `Chose files ${validatedParams.paths.join(', ')}`, async () => {
|
|
||||||
await context.submitFileChooser(validatedParams.paths);
|
const code = [
|
||||||
}, snapshot);
|
`// Resize browser window to ${params.width}x${params.height}`,
|
||||||
|
`await page.setViewportSize({ width: ${params.width}, height: ${params.height} });`
|
||||||
|
];
|
||||||
|
|
||||||
|
const action = async () => {
|
||||||
|
await tab.page.setViewportSize({ width: params.width, height: params.height });
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
code,
|
||||||
|
action,
|
||||||
|
captureSnapshot,
|
||||||
|
waitForNetwork: true
|
||||||
|
};
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
export const install: Tool = {
|
export default (captureSnapshot: boolean) => [
|
||||||
schema: {
|
close,
|
||||||
name: 'browser_install',
|
resize(captureSnapshot)
|
||||||
description: 'Install the browser specified in the config. Call this if you get an error about the browser not being installed.',
|
];
|
||||||
inputSchema: zodToJsonSchema(z.object({})),
|
|
||||||
},
|
|
||||||
handle: async context => {
|
|
||||||
const channel = await context.install();
|
|
||||||
return {
|
|
||||||
content: [{
|
|
||||||
type: 'text',
|
|
||||||
text: `Browser ${channel} installed`,
|
|
||||||
}],
|
|
||||||
};
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|||||||
47
src/tools/console.ts
Normal file
47
src/tools/console.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 { defineTool } from './tool.js';
|
||||||
|
|
||||||
|
const console = defineTool({
|
||||||
|
capability: 'core',
|
||||||
|
schema: {
|
||||||
|
name: 'browser_console_messages',
|
||||||
|
title: 'Get console messages',
|
||||||
|
description: 'Returns all console messages',
|
||||||
|
inputSchema: z.object({}),
|
||||||
|
type: 'readOnly',
|
||||||
|
},
|
||||||
|
handle: async context => {
|
||||||
|
const messages = context.currentTabOrDie().consoleMessages();
|
||||||
|
const log = messages.map(message => `[${message.type().toUpperCase()}] ${message.text()}`).join('\n');
|
||||||
|
return {
|
||||||
|
code: [`// <internal code to get console messages>`],
|
||||||
|
action: async () => {
|
||||||
|
return {
|
||||||
|
content: [{ type: 'text', text: log }]
|
||||||
|
};
|
||||||
|
},
|
||||||
|
captureSnapshot: false,
|
||||||
|
waitForNetwork: false,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export default [
|
||||||
|
console,
|
||||||
|
];
|
||||||
62
src/tools/dialogs.ts
Normal file
62
src/tools/dialogs.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 { defineTool, type ToolFactory } from './tool.js';
|
||||||
|
|
||||||
|
const handleDialog: ToolFactory = captureSnapshot => defineTool({
|
||||||
|
capability: 'core',
|
||||||
|
|
||||||
|
schema: {
|
||||||
|
name: 'browser_handle_dialog',
|
||||||
|
title: 'Handle a dialog',
|
||||||
|
description: 'Handle a dialog',
|
||||||
|
inputSchema: z.object({
|
||||||
|
accept: z.boolean().describe('Whether to accept the dialog.'),
|
||||||
|
promptText: z.string().optional().describe('The text of the prompt in case of a prompt dialog.'),
|
||||||
|
}),
|
||||||
|
type: 'destructive',
|
||||||
|
},
|
||||||
|
|
||||||
|
handle: async (context, params) => {
|
||||||
|
const dialogState = context.modalStates().find(state => state.type === 'dialog');
|
||||||
|
if (!dialogState)
|
||||||
|
throw new Error('No dialog visible');
|
||||||
|
|
||||||
|
if (params.accept)
|
||||||
|
await dialogState.dialog.accept(params.promptText);
|
||||||
|
else
|
||||||
|
await dialogState.dialog.dismiss();
|
||||||
|
|
||||||
|
context.clearModalState(dialogState);
|
||||||
|
|
||||||
|
const code = [
|
||||||
|
`// <internal code to handle "${dialogState.dialog.type()}" dialog>`,
|
||||||
|
];
|
||||||
|
|
||||||
|
return {
|
||||||
|
code,
|
||||||
|
captureSnapshot,
|
||||||
|
waitForNetwork: false,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
clearsModalState: 'dialog',
|
||||||
|
});
|
||||||
|
|
||||||
|
export default (captureSnapshot: boolean) => [
|
||||||
|
handleDialog(captureSnapshot),
|
||||||
|
];
|
||||||
59
src/tools/files.ts
Normal file
59
src/tools/files.ts
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
/**
|
||||||
|
* Copyright (c) Microsoft Corporation.
|
||||||
|
*
|
||||||
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
* you may not use this file except in compliance with the License.
|
||||||
|
* You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing, software
|
||||||
|
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
* See the License for the specific language governing permissions and
|
||||||
|
* limitations under the License.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { z } from 'zod';
|
||||||
|
import { defineTool, type ToolFactory } from './tool.js';
|
||||||
|
|
||||||
|
const uploadFile: ToolFactory = captureSnapshot => defineTool({
|
||||||
|
capability: 'files',
|
||||||
|
|
||||||
|
schema: {
|
||||||
|
name: 'browser_file_upload',
|
||||||
|
title: 'Upload files',
|
||||||
|
description: 'Upload one or multiple files',
|
||||||
|
inputSchema: z.object({
|
||||||
|
paths: z.array(z.string()).describe('The absolute paths to the files to upload. Can be a single file or multiple files.'),
|
||||||
|
}),
|
||||||
|
type: 'destructive',
|
||||||
|
},
|
||||||
|
|
||||||
|
handle: async (context, params) => {
|
||||||
|
const modalState = context.modalStates().find(state => state.type === 'fileChooser');
|
||||||
|
if (!modalState)
|
||||||
|
throw new Error('No file chooser visible');
|
||||||
|
|
||||||
|
const code = [
|
||||||
|
`// <internal code to chose files ${params.paths.join(', ')}`,
|
||||||
|
];
|
||||||
|
|
||||||
|
const action = async () => {
|
||||||
|
await modalState.fileChooser.setFiles(params.paths);
|
||||||
|
context.clearModalState(modalState);
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
code,
|
||||||
|
action,
|
||||||
|
captureSnapshot,
|
||||||
|
waitForNetwork: true,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
clearsModalState: 'fileChooser',
|
||||||
|
});
|
||||||
|
|
||||||
|
export default (captureSnapshot: boolean) => [
|
||||||
|
uploadFile(captureSnapshot),
|
||||||
|
];
|
||||||
63
src/tools/install.ts
Normal file
63
src/tools/install.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 { fork } from 'child_process';
|
||||||
|
import path from 'path';
|
||||||
|
|
||||||
|
import { z } from 'zod';
|
||||||
|
import { defineTool } from './tool.js';
|
||||||
|
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
|
||||||
|
const install = defineTool({
|
||||||
|
capability: 'install',
|
||||||
|
schema: {
|
||||||
|
name: 'browser_install',
|
||||||
|
title: 'Install the browser specified in the config',
|
||||||
|
description: 'Install the browser specified in the config. Call this if you get an error about the browser not being installed.',
|
||||||
|
inputSchema: z.object({}),
|
||||||
|
type: 'destructive',
|
||||||
|
},
|
||||||
|
|
||||||
|
handle: async context => {
|
||||||
|
const channel = context.config.browser?.launchOptions?.channel ?? context.config.browser?.browserName ?? 'chrome';
|
||||||
|
const cliUrl = import.meta.resolve('playwright/package.json');
|
||||||
|
const cliPath = path.join(fileURLToPath(cliUrl), '..', 'cli.js');
|
||||||
|
const child = fork(cliPath, ['install', channel], {
|
||||||
|
stdio: 'pipe',
|
||||||
|
});
|
||||||
|
const output: string[] = [];
|
||||||
|
child.stdout?.on('data', data => output.push(data.toString()));
|
||||||
|
child.stderr?.on('data', data => output.push(data.toString()));
|
||||||
|
await new Promise<void>((resolve, reject) => {
|
||||||
|
child.on('close', code => {
|
||||||
|
if (code === 0)
|
||||||
|
resolve();
|
||||||
|
else
|
||||||
|
reject(new Error(`Failed to install browser: ${output.join('')}`));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
code: [`// Browser ${channel} installed`],
|
||||||
|
captureSnapshot: false,
|
||||||
|
waitForNetwork: false,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export default [
|
||||||
|
install,
|
||||||
|
];
|
||||||
54
src/tools/keyboard.ts
Normal file
54
src/tools/keyboard.ts
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
/**
|
||||||
|
* Copyright (c) Microsoft Corporation.
|
||||||
|
*
|
||||||
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
* you may not use this file except in compliance with the License.
|
||||||
|
* You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing, software
|
||||||
|
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
* See the License for the specific language governing permissions and
|
||||||
|
* limitations under the License.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { z } from 'zod';
|
||||||
|
import { defineTool, type ToolFactory } from './tool.js';
|
||||||
|
|
||||||
|
const pressKey: ToolFactory = captureSnapshot => defineTool({
|
||||||
|
capability: 'core',
|
||||||
|
|
||||||
|
schema: {
|
||||||
|
name: 'browser_press_key',
|
||||||
|
title: 'Press a key',
|
||||||
|
description: 'Press a key on the keyboard',
|
||||||
|
inputSchema: z.object({
|
||||||
|
key: z.string().describe('Name of the key to press or a character to generate, such as `ArrowLeft` or `a`'),
|
||||||
|
}),
|
||||||
|
type: 'destructive',
|
||||||
|
},
|
||||||
|
|
||||||
|
handle: async (context, params) => {
|
||||||
|
const tab = context.currentTabOrDie();
|
||||||
|
|
||||||
|
const code = [
|
||||||
|
`// Press ${params.key}`,
|
||||||
|
`await page.keyboard.press('${params.key}');`,
|
||||||
|
];
|
||||||
|
|
||||||
|
const action = () => tab.page.keyboard.press(params.key);
|
||||||
|
|
||||||
|
return {
|
||||||
|
code,
|
||||||
|
action,
|
||||||
|
captureSnapshot,
|
||||||
|
waitForNetwork: true
|
||||||
|
};
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export default (captureSnapshot: boolean) => [
|
||||||
|
pressKey(captureSnapshot),
|
||||||
|
];
|
||||||
104
src/tools/navigate.ts
Normal file
104
src/tools/navigate.ts
Normal file
@@ -0,0 +1,104 @@
|
|||||||
|
/**
|
||||||
|
* Copyright (c) Microsoft Corporation.
|
||||||
|
*
|
||||||
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
* you may not use this file except in compliance with the License.
|
||||||
|
* You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing, software
|
||||||
|
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
* See the License for the specific language governing permissions and
|
||||||
|
* limitations under the License.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { z } from 'zod';
|
||||||
|
import { defineTool, type ToolFactory } from './tool.js';
|
||||||
|
|
||||||
|
const navigate: ToolFactory = captureSnapshot => defineTool({
|
||||||
|
capability: 'core',
|
||||||
|
|
||||||
|
schema: {
|
||||||
|
name: 'browser_navigate',
|
||||||
|
title: 'Navigate to a URL',
|
||||||
|
description: 'Navigate to a URL',
|
||||||
|
inputSchema: z.object({
|
||||||
|
url: z.string().describe('The URL to navigate to'),
|
||||||
|
}),
|
||||||
|
type: 'destructive',
|
||||||
|
},
|
||||||
|
|
||||||
|
handle: async (context, params) => {
|
||||||
|
const tab = await context.ensureTab();
|
||||||
|
await tab.navigate(params.url);
|
||||||
|
|
||||||
|
const code = [
|
||||||
|
`// Navigate to ${params.url}`,
|
||||||
|
`await page.goto('${params.url}');`,
|
||||||
|
];
|
||||||
|
|
||||||
|
return {
|
||||||
|
code,
|
||||||
|
captureSnapshot,
|
||||||
|
waitForNetwork: false,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const goBack: ToolFactory = captureSnapshot => defineTool({
|
||||||
|
capability: 'history',
|
||||||
|
schema: {
|
||||||
|
name: 'browser_navigate_back',
|
||||||
|
title: 'Go back',
|
||||||
|
description: 'Go back to the previous page',
|
||||||
|
inputSchema: z.object({}),
|
||||||
|
type: 'readOnly',
|
||||||
|
},
|
||||||
|
|
||||||
|
handle: async context => {
|
||||||
|
const tab = await context.ensureTab();
|
||||||
|
await tab.page.goBack();
|
||||||
|
const code = [
|
||||||
|
`// Navigate back`,
|
||||||
|
`await page.goBack();`,
|
||||||
|
];
|
||||||
|
|
||||||
|
return {
|
||||||
|
code,
|
||||||
|
captureSnapshot,
|
||||||
|
waitForNetwork: false,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const goForward: ToolFactory = captureSnapshot => defineTool({
|
||||||
|
capability: 'history',
|
||||||
|
schema: {
|
||||||
|
name: 'browser_navigate_forward',
|
||||||
|
title: 'Go forward',
|
||||||
|
description: 'Go forward to the next page',
|
||||||
|
inputSchema: z.object({}),
|
||||||
|
type: 'readOnly',
|
||||||
|
},
|
||||||
|
handle: async context => {
|
||||||
|
const tab = context.currentTabOrDie();
|
||||||
|
await tab.page.goForward();
|
||||||
|
const code = [
|
||||||
|
`// Navigate forward`,
|
||||||
|
`await page.goForward();`,
|
||||||
|
];
|
||||||
|
return {
|
||||||
|
code,
|
||||||
|
captureSnapshot,
|
||||||
|
waitForNetwork: false,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export default (captureSnapshot: boolean) => [
|
||||||
|
navigate(captureSnapshot),
|
||||||
|
goBack(captureSnapshot),
|
||||||
|
goForward(captureSnapshot),
|
||||||
|
];
|
||||||
59
src/tools/network.ts
Normal file
59
src/tools/network.ts
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
/**
|
||||||
|
* Copyright (c) Microsoft Corporation.
|
||||||
|
*
|
||||||
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
* you may not use this file except in compliance with the License.
|
||||||
|
* You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing, software
|
||||||
|
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
* See the License for the specific language governing permissions and
|
||||||
|
* limitations under the License.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { z } from 'zod';
|
||||||
|
import { defineTool } from './tool.js';
|
||||||
|
|
||||||
|
import type * as playwright from 'playwright';
|
||||||
|
|
||||||
|
const requests = defineTool({
|
||||||
|
capability: 'core',
|
||||||
|
|
||||||
|
schema: {
|
||||||
|
name: 'browser_network_requests',
|
||||||
|
title: 'List network requests',
|
||||||
|
description: 'Returns all network requests since loading the page',
|
||||||
|
inputSchema: z.object({}),
|
||||||
|
type: 'readOnly',
|
||||||
|
},
|
||||||
|
|
||||||
|
handle: async context => {
|
||||||
|
const requests = context.currentTabOrDie().requests();
|
||||||
|
const log = [...requests.entries()].map(([request, response]) => renderRequest(request, response)).join('\n');
|
||||||
|
return {
|
||||||
|
code: [`// <internal code to list network requests>`],
|
||||||
|
action: async () => {
|
||||||
|
return {
|
||||||
|
content: [{ type: 'text', text: log }]
|
||||||
|
};
|
||||||
|
},
|
||||||
|
captureSnapshot: false,
|
||||||
|
waitForNetwork: false,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
function renderRequest(request: playwright.Request, response: playwright.Response | null) {
|
||||||
|
const result: string[] = [];
|
||||||
|
result.push(`[${request.method().toUpperCase()}] ${request.url()}`);
|
||||||
|
if (response)
|
||||||
|
result.push(`=> [${response.status()}] ${response.statusText()}`);
|
||||||
|
return result.join(' ');
|
||||||
|
}
|
||||||
|
|
||||||
|
export default [
|
||||||
|
requests,
|
||||||
|
];
|
||||||
58
src/tools/pdf.ts
Normal file
58
src/tools/pdf.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 { z } from 'zod';
|
||||||
|
import { defineTool } from './tool.js';
|
||||||
|
|
||||||
|
import * as javascript from '../javascript.js';
|
||||||
|
import { outputFile } from '../config.js';
|
||||||
|
|
||||||
|
const pdfSchema = z.object({
|
||||||
|
filename: z.string().optional().describe('File name to save the pdf to. Defaults to `page-{timestamp}.pdf` if not specified.'),
|
||||||
|
});
|
||||||
|
|
||||||
|
const pdf = defineTool({
|
||||||
|
capability: 'pdf',
|
||||||
|
|
||||||
|
schema: {
|
||||||
|
name: 'browser_pdf_save',
|
||||||
|
title: 'Save as PDF',
|
||||||
|
description: 'Save page as PDF',
|
||||||
|
inputSchema: pdfSchema,
|
||||||
|
type: 'readOnly',
|
||||||
|
},
|
||||||
|
|
||||||
|
handle: async (context, params) => {
|
||||||
|
const tab = context.currentTabOrDie();
|
||||||
|
const fileName = await outputFile(context.config, params.filename ?? `page-${new Date().toISOString()}.pdf`);
|
||||||
|
|
||||||
|
const code = [
|
||||||
|
`// Save page as ${fileName}`,
|
||||||
|
`await page.pdf(${javascript.formatObject({ path: fileName })});`,
|
||||||
|
];
|
||||||
|
|
||||||
|
return {
|
||||||
|
code,
|
||||||
|
action: async () => tab.page.pdf({ path: fileName }).then(() => {}),
|
||||||
|
captureSnapshot: false,
|
||||||
|
waitForNetwork: false,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export default [
|
||||||
|
pdf,
|
||||||
|
];
|
||||||
@@ -15,119 +15,76 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
import { zodToJsonSchema } from 'zod-to-json-schema';
|
|
||||||
|
|
||||||
import { runAndWait } from './utils';
|
import { defineTool } from './tool.js';
|
||||||
|
import * as javascript from '../javascript.js';
|
||||||
|
import { outputFile } from '../config.js';
|
||||||
|
import { generateLocator } from './utils.js';
|
||||||
|
|
||||||
import type { Tool } from './tool';
|
import type * as playwright from 'playwright';
|
||||||
|
|
||||||
export const screenshot: Tool = {
|
const screenshotSchema = z.object({
|
||||||
|
raw: z.boolean().optional().describe('Whether to return without compression (in PNG format). Default is false, which returns a JPEG image.'),
|
||||||
|
filename: z.string().optional().describe('File name to save the screenshot to. Defaults to `page-{timestamp}.{png|jpeg}` if not specified.'),
|
||||||
|
element: z.string().optional().describe('Human-readable element description used to obtain permission to screenshot the element. If not provided, the screenshot will be taken of viewport. If element is provided, ref must be provided too.'),
|
||||||
|
ref: z.string().optional().describe('Exact target element reference from the page snapshot. If not provided, the screenshot will be taken of viewport. If ref is provided, element must be provided too.'),
|
||||||
|
}).refine(data => {
|
||||||
|
return !!data.element === !!data.ref;
|
||||||
|
}, {
|
||||||
|
message: 'Both element and ref must be provided or neither.',
|
||||||
|
path: ['ref', 'element']
|
||||||
|
});
|
||||||
|
|
||||||
|
const screenshot = defineTool({
|
||||||
|
capability: 'core',
|
||||||
schema: {
|
schema: {
|
||||||
name: 'browser_screenshot',
|
name: 'browser_take_screenshot',
|
||||||
description: 'Take a screenshot of the current page',
|
title: 'Take a screenshot',
|
||||||
inputSchema: zodToJsonSchema(z.object({})),
|
description: `Take a screenshot of the current page. You can't perform actions based on the screenshot, use browser_snapshot for actions.`,
|
||||||
|
inputSchema: screenshotSchema,
|
||||||
|
type: 'readOnly',
|
||||||
},
|
},
|
||||||
|
|
||||||
handle: async context => {
|
handle: async (context, params) => {
|
||||||
const page = context.existingPage();
|
const tab = context.currentTabOrDie();
|
||||||
const screenshot = await page.screenshot({ type: 'jpeg', quality: 50, scale: 'css' });
|
const snapshot = tab.snapshotOrDie();
|
||||||
return {
|
const fileType = params.raw ? 'png' : 'jpeg';
|
||||||
content: [{ type: 'image', data: screenshot.toString('base64'), mimeType: 'image/jpeg' }],
|
const fileName = await outputFile(context.config, params.filename ?? `page-${new Date().toISOString()}.${fileType}`);
|
||||||
|
const options: playwright.PageScreenshotOptions = { type: fileType, quality: fileType === 'png' ? undefined : 50, scale: 'css', path: fileName };
|
||||||
|
const isElementScreenshot = params.element && params.ref;
|
||||||
|
|
||||||
|
const code = [
|
||||||
|
`// Screenshot ${isElementScreenshot ? params.element : 'viewport'} and save it as ${fileName}`,
|
||||||
|
];
|
||||||
|
|
||||||
|
const locator = params.ref ? snapshot.refLocator({ element: params.element || '', ref: params.ref }) : null;
|
||||||
|
|
||||||
|
if (locator)
|
||||||
|
code.push(`await page.${await generateLocator(locator)}.screenshot(${javascript.formatObject(options)});`);
|
||||||
|
else
|
||||||
|
code.push(`await page.screenshot(${javascript.formatObject(options)});`);
|
||||||
|
|
||||||
|
const includeBase64 = context.clientSupportsImages();
|
||||||
|
const action = async () => {
|
||||||
|
const screenshot = locator ? await locator.screenshot(options) : await tab.page.screenshot(options);
|
||||||
|
return {
|
||||||
|
content: includeBase64 ? [{
|
||||||
|
type: 'image' as 'image',
|
||||||
|
data: screenshot.toString('base64'),
|
||||||
|
mimeType: fileType === 'png' ? 'image/png' : 'image/jpeg',
|
||||||
|
}] : []
|
||||||
|
};
|
||||||
};
|
};
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
const elementSchema = z.object({
|
|
||||||
element: z.string().describe('Human-readable element description used to obtain permission to interact with the element'),
|
|
||||||
});
|
|
||||||
|
|
||||||
const moveMouseSchema = elementSchema.extend({
|
|
||||||
x: z.number().describe('X coordinate'),
|
|
||||||
y: z.number().describe('Y coordinate'),
|
|
||||||
});
|
|
||||||
|
|
||||||
export const moveMouse: Tool = {
|
|
||||||
schema: {
|
|
||||||
name: 'browser_move_mouse',
|
|
||||||
description: 'Move mouse to a given position',
|
|
||||||
inputSchema: zodToJsonSchema(moveMouseSchema),
|
|
||||||
},
|
|
||||||
|
|
||||||
handle: async (context, params) => {
|
|
||||||
const validatedParams = moveMouseSchema.parse(params);
|
|
||||||
const page = context.existingPage();
|
|
||||||
await page.mouse.move(validatedParams.x, validatedParams.y);
|
|
||||||
return {
|
return {
|
||||||
content: [{ type: 'text', text: `Moved mouse to (${validatedParams.x}, ${validatedParams.y})` }],
|
code,
|
||||||
|
action,
|
||||||
|
captureSnapshot: true,
|
||||||
|
waitForNetwork: false,
|
||||||
};
|
};
|
||||||
},
|
}
|
||||||
};
|
|
||||||
|
|
||||||
const clickSchema = elementSchema.extend({
|
|
||||||
x: z.number().describe('X coordinate'),
|
|
||||||
y: z.number().describe('Y coordinate'),
|
|
||||||
});
|
});
|
||||||
|
|
||||||
export const click: Tool = {
|
export default [
|
||||||
schema: {
|
screenshot,
|
||||||
name: 'browser_click',
|
];
|
||||||
description: 'Click left mouse button',
|
|
||||||
inputSchema: zodToJsonSchema(clickSchema),
|
|
||||||
},
|
|
||||||
|
|
||||||
handle: async (context, params) => {
|
|
||||||
return await runAndWait(context, 'Clicked mouse', async page => {
|
|
||||||
const validatedParams = clickSchema.parse(params);
|
|
||||||
await page.mouse.move(validatedParams.x, validatedParams.y);
|
|
||||||
await page.mouse.down();
|
|
||||||
await page.mouse.up();
|
|
||||||
});
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
const dragSchema = elementSchema.extend({
|
|
||||||
startX: z.number().describe('Start X coordinate'),
|
|
||||||
startY: z.number().describe('Start Y coordinate'),
|
|
||||||
endX: z.number().describe('End X coordinate'),
|
|
||||||
endY: z.number().describe('End Y coordinate'),
|
|
||||||
});
|
|
||||||
|
|
||||||
export const drag: Tool = {
|
|
||||||
schema: {
|
|
||||||
name: 'browser_drag',
|
|
||||||
description: 'Drag left mouse button',
|
|
||||||
inputSchema: zodToJsonSchema(dragSchema),
|
|
||||||
},
|
|
||||||
|
|
||||||
handle: async (context, params) => {
|
|
||||||
const validatedParams = dragSchema.parse(params);
|
|
||||||
return await runAndWait(context, `Dragged mouse from (${validatedParams.startX}, ${validatedParams.startY}) to (${validatedParams.endX}, ${validatedParams.endY})`, async page => {
|
|
||||||
await page.mouse.move(validatedParams.startX, validatedParams.startY);
|
|
||||||
await page.mouse.down();
|
|
||||||
await page.mouse.move(validatedParams.endX, validatedParams.endY);
|
|
||||||
await page.mouse.up();
|
|
||||||
});
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
const typeSchema = z.object({
|
|
||||||
text: z.string().describe('Text to type into the element'),
|
|
||||||
submit: z.boolean().describe('Whether to submit entered text (press Enter after)'),
|
|
||||||
});
|
|
||||||
|
|
||||||
export const type: Tool = {
|
|
||||||
schema: {
|
|
||||||
name: 'browser_type',
|
|
||||||
description: 'Type text',
|
|
||||||
inputSchema: zodToJsonSchema(typeSchema),
|
|
||||||
},
|
|
||||||
|
|
||||||
handle: async (context, params) => {
|
|
||||||
const validatedParams = typeSchema.parse(params);
|
|
||||||
return await runAndWait(context, `Typed text "${validatedParams.text}"`, async page => {
|
|
||||||
await page.keyboard.type(validatedParams.text);
|
|
||||||
if (validatedParams.submit)
|
|
||||||
await page.keyboard.press('Enter');
|
|
||||||
});
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|||||||
@@ -15,141 +15,220 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
import zodToJsonSchema from 'zod-to-json-schema';
|
|
||||||
|
|
||||||
import { captureAriaSnapshot, runAndWait } from './utils';
|
import { defineTool } from './tool.js';
|
||||||
|
import * as javascript from '../javascript.js';
|
||||||
|
import { generateLocator } from './utils.js';
|
||||||
|
|
||||||
import type * as playwright from 'playwright';
|
const snapshot = defineTool({
|
||||||
import type { Tool } from './tool';
|
capability: 'core',
|
||||||
|
|
||||||
export const snapshot: Tool = {
|
|
||||||
schema: {
|
schema: {
|
||||||
name: 'browser_snapshot',
|
name: 'browser_snapshot',
|
||||||
|
title: 'Page snapshot',
|
||||||
description: 'Capture accessibility snapshot of the current page, this is better than screenshot',
|
description: 'Capture accessibility snapshot of the current page, this is better than screenshot',
|
||||||
inputSchema: zodToJsonSchema(z.object({})),
|
inputSchema: z.object({}),
|
||||||
|
type: 'readOnly',
|
||||||
},
|
},
|
||||||
|
|
||||||
handle: async context => {
|
handle: async context => {
|
||||||
return await captureAriaSnapshot(context);
|
await context.ensureTab();
|
||||||
|
|
||||||
|
return {
|
||||||
|
code: [`// <internal code to capture accessibility snapshot>`],
|
||||||
|
captureSnapshot: true,
|
||||||
|
waitForNetwork: false,
|
||||||
|
};
|
||||||
},
|
},
|
||||||
};
|
});
|
||||||
|
|
||||||
const elementSchema = z.object({
|
const elementSchema = z.object({
|
||||||
element: z.string().describe('Human-readable element description used to obtain permission to interact with the element'),
|
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'),
|
ref: z.string().describe('Exact target element reference from the page snapshot'),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const click: Tool = {
|
const clickSchema = elementSchema.extend({
|
||||||
schema: {
|
doubleClick: z.boolean().optional().describe('Whether to perform a double click instead of a single click'),
|
||||||
name: 'browser_click',
|
|
||||||
description: 'Perform click on a web page',
|
|
||||||
inputSchema: zodToJsonSchema(elementSchema),
|
|
||||||
},
|
|
||||||
|
|
||||||
handle: async (context, params) => {
|
|
||||||
const validatedParams = elementSchema.parse(params);
|
|
||||||
return runAndWait(context, `"${validatedParams.element}" clicked`, () => context.refLocator(validatedParams.ref).click(), true);
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
const dragSchema = z.object({
|
|
||||||
startElement: z.string().describe('Human-readable source element description used to obtain the permission to interact with the element'),
|
|
||||||
startRef: z.string().describe('Exact source element reference from the page snapshot'),
|
|
||||||
endElement: z.string().describe('Human-readable target element description used to obtain the permission to interact with the element'),
|
|
||||||
endRef: z.string().describe('Exact target element reference from the page snapshot'),
|
|
||||||
});
|
});
|
||||||
|
|
||||||
export const drag: Tool = {
|
const click = defineTool({
|
||||||
|
capability: 'core',
|
||||||
|
schema: {
|
||||||
|
name: 'browser_click',
|
||||||
|
title: 'Click',
|
||||||
|
description: 'Perform click on a web page',
|
||||||
|
inputSchema: clickSchema,
|
||||||
|
type: 'destructive',
|
||||||
|
},
|
||||||
|
|
||||||
|
handle: async (context, params) => {
|
||||||
|
const tab = context.currentTabOrDie();
|
||||||
|
const locator = tab.snapshotOrDie().refLocator(params);
|
||||||
|
|
||||||
|
const code: string[] = [];
|
||||||
|
if (params.doubleClick) {
|
||||||
|
code.push(`// Double click ${params.element}`);
|
||||||
|
code.push(`await page.${await generateLocator(locator)}.dblclick();`);
|
||||||
|
} else {
|
||||||
|
code.push(`// Click ${params.element}`);
|
||||||
|
code.push(`await page.${await generateLocator(locator)}.click();`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
code,
|
||||||
|
action: () => params.doubleClick ? locator.dblclick() : locator.click(),
|
||||||
|
captureSnapshot: true,
|
||||||
|
waitForNetwork: true,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const drag = defineTool({
|
||||||
|
capability: 'core',
|
||||||
schema: {
|
schema: {
|
||||||
name: 'browser_drag',
|
name: 'browser_drag',
|
||||||
|
title: 'Drag mouse',
|
||||||
description: 'Perform drag and drop between two elements',
|
description: 'Perform drag and drop between two elements',
|
||||||
inputSchema: zodToJsonSchema(dragSchema),
|
inputSchema: z.object({
|
||||||
|
startElement: z.string().describe('Human-readable source element description used to obtain the permission to interact with the element'),
|
||||||
|
startRef: z.string().describe('Exact source element reference from the page snapshot'),
|
||||||
|
endElement: z.string().describe('Human-readable target element description used to obtain the permission to interact with the element'),
|
||||||
|
endRef: z.string().describe('Exact target element reference from the page snapshot'),
|
||||||
|
}),
|
||||||
|
type: 'destructive',
|
||||||
},
|
},
|
||||||
|
|
||||||
handle: async (context, params) => {
|
handle: async (context, params) => {
|
||||||
const validatedParams = dragSchema.parse(params);
|
const snapshot = context.currentTabOrDie().snapshotOrDie();
|
||||||
return runAndWait(context, `Dragged "${validatedParams.startElement}" to "${validatedParams.endElement}"`, async () => {
|
const startLocator = snapshot.refLocator({ ref: params.startRef, element: params.startElement });
|
||||||
const startLocator = context.refLocator(validatedParams.startRef);
|
const endLocator = snapshot.refLocator({ ref: params.endRef, element: params.endElement });
|
||||||
const endLocator = context.refLocator(validatedParams.endRef);
|
|
||||||
await startLocator.dragTo(endLocator);
|
|
||||||
}, true);
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
export const hover: Tool = {
|
const code = [
|
||||||
|
`// Drag ${params.startElement} to ${params.endElement}`,
|
||||||
|
`await page.${await generateLocator(startLocator)}.dragTo(page.${await generateLocator(endLocator)});`
|
||||||
|
];
|
||||||
|
|
||||||
|
return {
|
||||||
|
code,
|
||||||
|
action: () => startLocator.dragTo(endLocator),
|
||||||
|
captureSnapshot: true,
|
||||||
|
waitForNetwork: true,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const hover = defineTool({
|
||||||
|
capability: 'core',
|
||||||
schema: {
|
schema: {
|
||||||
name: 'browser_hover',
|
name: 'browser_hover',
|
||||||
|
title: 'Hover mouse',
|
||||||
description: 'Hover over element on page',
|
description: 'Hover over element on page',
|
||||||
inputSchema: zodToJsonSchema(elementSchema),
|
inputSchema: elementSchema,
|
||||||
|
type: 'readOnly',
|
||||||
},
|
},
|
||||||
|
|
||||||
handle: async (context, params) => {
|
handle: async (context, params) => {
|
||||||
const validatedParams = elementSchema.parse(params);
|
const snapshot = context.currentTabOrDie().snapshotOrDie();
|
||||||
return runAndWait(context, `Hovered over "${validatedParams.element}"`, () => context.refLocator(validatedParams.ref).hover(), true);
|
const locator = snapshot.refLocator(params);
|
||||||
|
|
||||||
|
const code = [
|
||||||
|
`// Hover over ${params.element}`,
|
||||||
|
`await page.${await generateLocator(locator)}.hover();`
|
||||||
|
];
|
||||||
|
|
||||||
|
return {
|
||||||
|
code,
|
||||||
|
action: () => locator.hover(),
|
||||||
|
captureSnapshot: true,
|
||||||
|
waitForNetwork: true,
|
||||||
|
};
|
||||||
},
|
},
|
||||||
};
|
});
|
||||||
|
|
||||||
const typeSchema = elementSchema.extend({
|
const typeSchema = elementSchema.extend({
|
||||||
text: z.string().describe('Text to type into the element'),
|
text: z.string().describe('Text to type into the element'),
|
||||||
submit: z.boolean().describe('Whether to submit entered text (press Enter after)'),
|
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.'),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const type: Tool = {
|
const type = defineTool({
|
||||||
|
capability: 'core',
|
||||||
schema: {
|
schema: {
|
||||||
name: 'browser_type',
|
name: 'browser_type',
|
||||||
|
title: 'Type text',
|
||||||
description: 'Type text into editable element',
|
description: 'Type text into editable element',
|
||||||
inputSchema: zodToJsonSchema(typeSchema),
|
inputSchema: typeSchema,
|
||||||
|
type: 'destructive',
|
||||||
},
|
},
|
||||||
|
|
||||||
handle: async (context, params) => {
|
handle: async (context, params) => {
|
||||||
const validatedParams = typeSchema.parse(params);
|
const snapshot = context.currentTabOrDie().snapshotOrDie();
|
||||||
return await runAndWait(context, `Typed "${validatedParams.text}" into "${validatedParams.element}"`, async () => {
|
const locator = snapshot.refLocator(params);
|
||||||
const locator = context.refLocator(validatedParams.ref);
|
|
||||||
await locator.fill(validatedParams.text);
|
const code: string[] = [];
|
||||||
if (validatedParams.submit)
|
const steps: (() => Promise<void>)[] = [];
|
||||||
await locator.press('Enter');
|
|
||||||
}, true);
|
if (params.slowly) {
|
||||||
|
code.push(`// Press "${params.text}" sequentially into "${params.element}"`);
|
||||||
|
code.push(`await page.${await generateLocator(locator)}.pressSequentially(${javascript.quote(params.text)});`);
|
||||||
|
steps.push(() => locator.pressSequentially(params.text));
|
||||||
|
} else {
|
||||||
|
code.push(`// Fill "${params.text}" into "${params.element}"`);
|
||||||
|
code.push(`await page.${await generateLocator(locator)}.fill(${javascript.quote(params.text)});`);
|
||||||
|
steps.push(() => locator.fill(params.text));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (params.submit) {
|
||||||
|
code.push(`// Submit text`);
|
||||||
|
code.push(`await page.${await generateLocator(locator)}.press('Enter');`);
|
||||||
|
steps.push(() => locator.press('Enter'));
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
code,
|
||||||
|
action: () => steps.reduce((acc, step) => acc.then(step), Promise.resolve()),
|
||||||
|
captureSnapshot: true,
|
||||||
|
waitForNetwork: true,
|
||||||
|
};
|
||||||
},
|
},
|
||||||
};
|
});
|
||||||
|
|
||||||
const selectOptionSchema = elementSchema.extend({
|
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.'),
|
values: z.array(z.string()).describe('Array of values to select in the dropdown. This can be a single value or multiple values.'),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const selectOption: Tool = {
|
const selectOption = defineTool({
|
||||||
|
capability: 'core',
|
||||||
schema: {
|
schema: {
|
||||||
name: 'browser_select_option',
|
name: 'browser_select_option',
|
||||||
|
title: 'Select option',
|
||||||
description: 'Select an option in a dropdown',
|
description: 'Select an option in a dropdown',
|
||||||
inputSchema: zodToJsonSchema(selectOptionSchema),
|
inputSchema: selectOptionSchema,
|
||||||
|
type: 'destructive',
|
||||||
},
|
},
|
||||||
|
|
||||||
handle: async (context, params) => {
|
handle: async (context, params) => {
|
||||||
const validatedParams = selectOptionSchema.parse(params);
|
const snapshot = context.currentTabOrDie().snapshotOrDie();
|
||||||
return await runAndWait(context, `Selected option in "${validatedParams.element}"`, async () => {
|
const locator = snapshot.refLocator(params);
|
||||||
const locator = context.refLocator(validatedParams.ref);
|
|
||||||
await locator.selectOption(validatedParams.values);
|
|
||||||
}, true);
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
const screenshotSchema = z.object({
|
const code = [
|
||||||
raw: z.boolean().optional().describe('Whether to return without compression (in PNG format). Default is false, which returns a JPEG image.'),
|
`// Select options [${params.values.join(', ')}] in ${params.element}`,
|
||||||
});
|
`await page.${await generateLocator(locator)}.selectOption(${javascript.formatObject(params.values)});`
|
||||||
|
];
|
||||||
|
|
||||||
export const screenshot: Tool = {
|
|
||||||
schema: {
|
|
||||||
name: 'browser_take_screenshot',
|
|
||||||
description: `Take a screenshot of the current page. You can't perform actions based on the screenshot, use browser_snapshot for actions.`,
|
|
||||||
inputSchema: zodToJsonSchema(screenshotSchema),
|
|
||||||
},
|
|
||||||
|
|
||||||
handle: async (context, params) => {
|
|
||||||
const validatedParams = screenshotSchema.parse(params);
|
|
||||||
const page = context.existingPage();
|
|
||||||
const options: playwright.PageScreenshotOptions = validatedParams.raw ? { type: 'png', scale: 'css' } : { type: 'jpeg', quality: 50, scale: 'css' };
|
|
||||||
const screenshot = await page.screenshot(options);
|
|
||||||
return {
|
return {
|
||||||
content: [{ type: 'image', data: screenshot.toString('base64'), mimeType: validatedParams.raw ? 'image/png' : 'image/jpeg' }],
|
code,
|
||||||
|
action: () => locator.selectOption(params.values).then(() => {}),
|
||||||
|
captureSnapshot: true,
|
||||||
|
waitForNetwork: true,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
};
|
});
|
||||||
|
|
||||||
|
export default [
|
||||||
|
snapshot,
|
||||||
|
click,
|
||||||
|
drag,
|
||||||
|
hover,
|
||||||
|
type,
|
||||||
|
selectOption,
|
||||||
|
];
|
||||||
|
|||||||
134
src/tools/tabs.ts
Normal file
134
src/tools/tabs.ts
Normal file
@@ -0,0 +1,134 @@
|
|||||||
|
/**
|
||||||
|
* Copyright (c) Microsoft Corporation.
|
||||||
|
*
|
||||||
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
* you may not use this file except in compliance with the License.
|
||||||
|
* You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing, software
|
||||||
|
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
* See the License for the specific language governing permissions and
|
||||||
|
* limitations under the License.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { z } from 'zod';
|
||||||
|
import { defineTool, type ToolFactory } from './tool.js';
|
||||||
|
|
||||||
|
const listTabs = defineTool({
|
||||||
|
capability: 'tabs',
|
||||||
|
|
||||||
|
schema: {
|
||||||
|
name: 'browser_tab_list',
|
||||||
|
title: 'List tabs',
|
||||||
|
description: 'List browser tabs',
|
||||||
|
inputSchema: z.object({}),
|
||||||
|
type: 'readOnly',
|
||||||
|
},
|
||||||
|
|
||||||
|
handle: async context => {
|
||||||
|
await context.ensureTab();
|
||||||
|
return {
|
||||||
|
code: [`// <internal code to list tabs>`],
|
||||||
|
captureSnapshot: false,
|
||||||
|
waitForNetwork: false,
|
||||||
|
resultOverride: {
|
||||||
|
content: [{
|
||||||
|
type: 'text',
|
||||||
|
text: await context.listTabsMarkdown(),
|
||||||
|
}],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const selectTab: ToolFactory = captureSnapshot => defineTool({
|
||||||
|
capability: 'tabs',
|
||||||
|
|
||||||
|
schema: {
|
||||||
|
name: 'browser_tab_select',
|
||||||
|
title: 'Select a tab',
|
||||||
|
description: 'Select a tab by index',
|
||||||
|
inputSchema: z.object({
|
||||||
|
index: z.number().describe('The index of the tab to select'),
|
||||||
|
}),
|
||||||
|
type: 'readOnly',
|
||||||
|
},
|
||||||
|
|
||||||
|
handle: async (context, params) => {
|
||||||
|
await context.selectTab(params.index);
|
||||||
|
const code = [
|
||||||
|
`// <internal code to select tab ${params.index}>`,
|
||||||
|
];
|
||||||
|
|
||||||
|
return {
|
||||||
|
code,
|
||||||
|
captureSnapshot,
|
||||||
|
waitForNetwork: false
|
||||||
|
};
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const newTab: ToolFactory = captureSnapshot => defineTool({
|
||||||
|
capability: 'tabs',
|
||||||
|
|
||||||
|
schema: {
|
||||||
|
name: 'browser_tab_new',
|
||||||
|
title: 'Open a new tab',
|
||||||
|
description: 'Open a new tab',
|
||||||
|
inputSchema: z.object({
|
||||||
|
url: z.string().optional().describe('The URL to navigate to in the new tab. If not provided, the new tab will be blank.'),
|
||||||
|
}),
|
||||||
|
type: 'readOnly',
|
||||||
|
},
|
||||||
|
|
||||||
|
handle: async (context, params) => {
|
||||||
|
await context.newTab();
|
||||||
|
if (params.url)
|
||||||
|
await context.currentTabOrDie().navigate(params.url);
|
||||||
|
|
||||||
|
const code = [
|
||||||
|
`// <internal code to open a new tab>`,
|
||||||
|
];
|
||||||
|
return {
|
||||||
|
code,
|
||||||
|
captureSnapshot,
|
||||||
|
waitForNetwork: false
|
||||||
|
};
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const closeTab: ToolFactory = captureSnapshot => defineTool({
|
||||||
|
capability: 'tabs',
|
||||||
|
|
||||||
|
schema: {
|
||||||
|
name: 'browser_tab_close',
|
||||||
|
title: 'Close a tab',
|
||||||
|
description: 'Close a tab',
|
||||||
|
inputSchema: z.object({
|
||||||
|
index: z.number().optional().describe('The index of the tab to close. Closes current tab if not provided.'),
|
||||||
|
}),
|
||||||
|
type: 'destructive',
|
||||||
|
},
|
||||||
|
|
||||||
|
handle: async (context, params) => {
|
||||||
|
await context.closeTab(params.index);
|
||||||
|
const code = [
|
||||||
|
`// <internal code to close tab ${params.index}>`,
|
||||||
|
];
|
||||||
|
return {
|
||||||
|
code,
|
||||||
|
captureSnapshot,
|
||||||
|
waitForNetwork: false
|
||||||
|
};
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export default (captureSnapshot: boolean) => [
|
||||||
|
listTabs,
|
||||||
|
newTab(captureSnapshot),
|
||||||
|
selectTab(captureSnapshot),
|
||||||
|
closeTab(captureSnapshot),
|
||||||
|
];
|
||||||
67
src/tools/testing.ts
Normal file
67
src/tools/testing.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 { z } from 'zod';
|
||||||
|
import { defineTool } from './tool.js';
|
||||||
|
|
||||||
|
const generateTestSchema = z.object({
|
||||||
|
name: z.string().describe('The name of the test'),
|
||||||
|
description: z.string().describe('The description of the test'),
|
||||||
|
steps: z.array(z.string()).describe('The steps of the test'),
|
||||||
|
});
|
||||||
|
|
||||||
|
const generateTest = defineTool({
|
||||||
|
capability: 'testing',
|
||||||
|
|
||||||
|
schema: {
|
||||||
|
name: 'browser_generate_playwright_test',
|
||||||
|
title: 'Generate a Playwright test',
|
||||||
|
description: 'Generate a Playwright test for given scenario',
|
||||||
|
inputSchema: generateTestSchema,
|
||||||
|
type: 'readOnly',
|
||||||
|
},
|
||||||
|
|
||||||
|
handle: async (context, params) => {
|
||||||
|
return {
|
||||||
|
resultOverride: {
|
||||||
|
content: [{
|
||||||
|
type: 'text',
|
||||||
|
text: instructions(params),
|
||||||
|
}],
|
||||||
|
},
|
||||||
|
code: [],
|
||||||
|
captureSnapshot: false,
|
||||||
|
waitForNetwork: false,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const instructions = (params: { name: string, description: string, steps: string[] }) => [
|
||||||
|
`## Instructions`,
|
||||||
|
`- You are a playwright test generator.`,
|
||||||
|
`- You are given a scenario and you need to generate a playwright test for it.`,
|
||||||
|
'- DO NOT generate test code based on the scenario alone. DO run steps one by one using the tools provided instead.',
|
||||||
|
'- Only after all steps are completed, emit a Playwright TypeScript test that uses @playwright/test based on message history',
|
||||||
|
'- Save generated test file in the tests directory',
|
||||||
|
`Test name: ${params.name}`,
|
||||||
|
`Description: ${params.description}`,
|
||||||
|
`Steps:`,
|
||||||
|
...params.steps.map((step, index) => `- ${index + 1}. ${step}`),
|
||||||
|
].join('\n');
|
||||||
|
|
||||||
|
export default [
|
||||||
|
generateTest,
|
||||||
|
];
|
||||||
@@ -14,24 +14,55 @@
|
|||||||
* limitations under the License.
|
* limitations under the License.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import type { ImageContent, TextContent } from '@modelcontextprotocol/sdk/types';
|
import type { ImageContent, TextContent } from '@modelcontextprotocol/sdk/types.js';
|
||||||
import type { JsonSchema7Type } from 'zod-to-json-schema';
|
import type { z } from 'zod';
|
||||||
import type { Context } from '../context';
|
import type { Context } from '../context.js';
|
||||||
|
import type * as playwright from 'playwright';
|
||||||
|
import type { ToolCapability } from '../../config.js';
|
||||||
|
|
||||||
export type ToolSchema = {
|
export type ToolSchema<Input extends InputType> = {
|
||||||
name: string;
|
name: string;
|
||||||
|
title: string;
|
||||||
description: string;
|
description: string;
|
||||||
inputSchema: JsonSchema7Type;
|
inputSchema: Input;
|
||||||
|
type: 'readOnly' | 'destructive';
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type InputType = z.Schema;
|
||||||
|
|
||||||
|
export type FileUploadModalState = {
|
||||||
|
type: 'fileChooser';
|
||||||
|
description: string;
|
||||||
|
fileChooser: playwright.FileChooser;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type DialogModalState = {
|
||||||
|
type: 'dialog';
|
||||||
|
description: string;
|
||||||
|
dialog: playwright.Dialog;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ModalState = FileUploadModalState | DialogModalState;
|
||||||
|
|
||||||
|
export type ToolActionResult = { content?: (ImageContent | TextContent)[] } | undefined | void;
|
||||||
|
|
||||||
export type ToolResult = {
|
export type ToolResult = {
|
||||||
content: (ImageContent | TextContent)[];
|
code: string[];
|
||||||
isError?: boolean;
|
action?: () => Promise<ToolActionResult>;
|
||||||
|
captureSnapshot: boolean;
|
||||||
|
waitForNetwork: boolean;
|
||||||
|
resultOverride?: ToolActionResult;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type Tool = {
|
export type Tool<Input extends InputType = InputType> = {
|
||||||
schema: ToolSchema;
|
capability: ToolCapability;
|
||||||
handle: (context: Context, params?: Record<string, any>) => Promise<ToolResult>;
|
schema: ToolSchema<Input>;
|
||||||
|
clearsModalState?: ModalState['type'];
|
||||||
|
handle: (context: Context, params: z.output<Input>) => Promise<ToolResult>;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type ToolFactory = (snapshot: boolean) => Tool;
|
export type ToolFactory = (snapshot: boolean) => Tool<any>;
|
||||||
|
|
||||||
|
export function defineTool<Input extends InputType>(tool: Tool<Input>): Tool<Input> {
|
||||||
|
return tool;
|
||||||
|
}
|
||||||
|
|||||||
@@ -15,10 +15,10 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import type * as playwright from 'playwright';
|
import type * as playwright from 'playwright';
|
||||||
import type { ToolResult } from './tool';
|
import type { Context } from '../context.js';
|
||||||
import type { Context } from '../context';
|
import type { Tab } from '../tab.js';
|
||||||
|
|
||||||
async function waitForCompletion<R>(page: playwright.Page, callback: () => Promise<R>): Promise<R> {
|
export async function waitForCompletion<R>(context: Context, tab: Tab, callback: () => Promise<R>): Promise<R> {
|
||||||
const requests = new Set<playwright.Request>();
|
const requests = new Set<playwright.Request>();
|
||||||
let frameNavigated = false;
|
let frameNavigated = false;
|
||||||
let waitCallback: () => void = () => {};
|
let waitCallback: () => void = () => {};
|
||||||
@@ -37,9 +37,7 @@ async function waitForCompletion<R>(page: playwright.Page, callback: () => Promi
|
|||||||
frameNavigated = true;
|
frameNavigated = true;
|
||||||
dispose();
|
dispose();
|
||||||
clearTimeout(timeout);
|
clearTimeout(timeout);
|
||||||
void frame.waitForLoadState('load').then(() => {
|
void tab.waitForLoadState('load').then(waitCallback);
|
||||||
waitCallback();
|
|
||||||
});
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const onTimeout = () => {
|
const onTimeout = () => {
|
||||||
@@ -47,15 +45,15 @@ async function waitForCompletion<R>(page: playwright.Page, callback: () => Promi
|
|||||||
waitCallback();
|
waitCallback();
|
||||||
};
|
};
|
||||||
|
|
||||||
page.on('request', requestListener);
|
tab.page.on('request', requestListener);
|
||||||
page.on('requestfinished', requestFinishedListener);
|
tab.page.on('requestfinished', requestFinishedListener);
|
||||||
page.on('framenavigated', frameNavigateListener);
|
tab.page.on('framenavigated', frameNavigateListener);
|
||||||
const timeout = setTimeout(onTimeout, 10000);
|
const timeout = setTimeout(onTimeout, 10000);
|
||||||
|
|
||||||
const dispose = () => {
|
const dispose = () => {
|
||||||
page.off('request', requestListener);
|
tab.page.off('request', requestListener);
|
||||||
page.off('requestfinished', requestFinishedListener);
|
tab.page.off('requestfinished', requestFinishedListener);
|
||||||
page.off('framenavigated', frameNavigateListener);
|
tab.page.off('framenavigated', frameNavigateListener);
|
||||||
clearTimeout(timeout);
|
clearTimeout(timeout);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -64,49 +62,31 @@ async function waitForCompletion<R>(page: playwright.Page, callback: () => Promi
|
|||||||
if (!requests.size && !frameNavigated)
|
if (!requests.size && !frameNavigated)
|
||||||
waitCallback();
|
waitCallback();
|
||||||
await waitBarrier;
|
await waitBarrier;
|
||||||
await page.evaluate(() => new Promise(f => setTimeout(f, 1000)));
|
await context.waitForTimeout(1000);
|
||||||
return result;
|
return result;
|
||||||
} finally {
|
} finally {
|
||||||
dispose();
|
dispose();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function runAndWait(context: Context, status: string, callback: (page: playwright.Page) => Promise<any>, snapshot: boolean = false): Promise<ToolResult> {
|
|
||||||
const page = context.existingPage();
|
|
||||||
const dismissFileChooser = context.hasFileChooser();
|
|
||||||
await waitForCompletion(page, () => callback(page));
|
|
||||||
if (dismissFileChooser)
|
|
||||||
context.clearFileChooser();
|
|
||||||
const result: ToolResult = snapshot ? await captureAriaSnapshot(context, status) : {
|
|
||||||
content: [{ type: 'text', text: status }],
|
|
||||||
};
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function captureAriaSnapshot(context: Context, status: string = ''): Promise<ToolResult> {
|
|
||||||
const page = context.existingPage();
|
|
||||||
const lines = [];
|
|
||||||
if (status)
|
|
||||||
lines.push(`${status}`);
|
|
||||||
lines.push(
|
|
||||||
'',
|
|
||||||
`- Page URL: ${page.url()}`,
|
|
||||||
`- Page Title: ${await page.title()}`
|
|
||||||
);
|
|
||||||
if (context.hasFileChooser())
|
|
||||||
lines.push(`- There is a file chooser visible that requires browser_choose_file to be called`);
|
|
||||||
lines.push(
|
|
||||||
`- Page Snapshot`,
|
|
||||||
'```yaml',
|
|
||||||
await context.allFramesSnapshot(),
|
|
||||||
'```',
|
|
||||||
''
|
|
||||||
);
|
|
||||||
return {
|
|
||||||
content: [{ type: 'text', text: lines.join('\n') }],
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export function sanitizeForFilePath(s: string) {
|
export function sanitizeForFilePath(s: string) {
|
||||||
return s.replace(/[\x00-\x2C\x2E-\x2F\x3A-\x40\x5B-\x60\x7B-\x7F]+/g, '-');
|
const sanitize = (s: string) => s.replace(/[\x00-\x2C\x2E-\x2F\x3A-\x40\x5B-\x60\x7B-\x7F]+/g, '-');
|
||||||
|
const separator = s.lastIndexOf('.');
|
||||||
|
if (separator === -1)
|
||||||
|
return sanitize(s);
|
||||||
|
return sanitize(s.substring(0, separator)) + '.' + sanitize(s.substring(separator + 1));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function generateLocator(locator: playwright.Locator): Promise<string> {
|
||||||
|
try {
|
||||||
|
return await (locator as any)._generateLocatorString();
|
||||||
|
} catch (e) {
|
||||||
|
if (e instanceof Error && /locator._generateLocatorString: No element matching locator/.test(e.message))
|
||||||
|
throw new Error('Ref not found, likely because element was removed. Use browser_snapshot to see what elements are currently on the page.');
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function callOnPageNoTrace<T>(page: playwright.Page, callback: (page: playwright.Page) => Promise<T>): Promise<T> {
|
||||||
|
return await (page as any)._wrapApiCall(() => callback(page), { internal: true });
|
||||||
}
|
}
|
||||||
|
|||||||
213
src/tools/vision.ts
Normal file
213
src/tools/vision.ts
Normal file
@@ -0,0 +1,213 @@
|
|||||||
|
/**
|
||||||
|
* Copyright (c) Microsoft Corporation.
|
||||||
|
*
|
||||||
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
* you may not use this file except in compliance with the License.
|
||||||
|
* You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing, software
|
||||||
|
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
* See the License for the specific language governing permissions and
|
||||||
|
* limitations under the License.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { z } from 'zod';
|
||||||
|
import { defineTool } from './tool.js';
|
||||||
|
|
||||||
|
import * as javascript from '../javascript.js';
|
||||||
|
|
||||||
|
const elementSchema = z.object({
|
||||||
|
element: z.string().describe('Human-readable element description used to obtain permission to interact with the element'),
|
||||||
|
});
|
||||||
|
|
||||||
|
const screenshot = defineTool({
|
||||||
|
capability: 'core',
|
||||||
|
schema: {
|
||||||
|
name: 'browser_screen_capture',
|
||||||
|
title: 'Take a screenshot',
|
||||||
|
description: 'Take a screenshot of the current page',
|
||||||
|
inputSchema: z.object({}),
|
||||||
|
type: 'readOnly',
|
||||||
|
},
|
||||||
|
|
||||||
|
handle: async context => {
|
||||||
|
const tab = await context.ensureTab();
|
||||||
|
const options = { type: 'jpeg' as 'jpeg', quality: 50, scale: 'css' as 'css' };
|
||||||
|
|
||||||
|
const code = [
|
||||||
|
`// Take a screenshot of the current page`,
|
||||||
|
`await page.screenshot(${javascript.formatObject(options)});`,
|
||||||
|
];
|
||||||
|
|
||||||
|
const action = () => tab.page.screenshot(options).then(buffer => {
|
||||||
|
return {
|
||||||
|
content: [{ type: 'image' as 'image', data: buffer.toString('base64'), mimeType: 'image/jpeg' }],
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
code,
|
||||||
|
action,
|
||||||
|
captureSnapshot: false,
|
||||||
|
waitForNetwork: false
|
||||||
|
};
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const moveMouse = defineTool({
|
||||||
|
capability: 'core',
|
||||||
|
schema: {
|
||||||
|
name: 'browser_screen_move_mouse',
|
||||||
|
title: 'Move mouse',
|
||||||
|
description: 'Move mouse to a given position',
|
||||||
|
inputSchema: elementSchema.extend({
|
||||||
|
x: z.number().describe('X coordinate'),
|
||||||
|
y: z.number().describe('Y coordinate'),
|
||||||
|
}),
|
||||||
|
type: 'readOnly',
|
||||||
|
},
|
||||||
|
|
||||||
|
handle: async (context, params) => {
|
||||||
|
const tab = context.currentTabOrDie();
|
||||||
|
const code = [
|
||||||
|
`// Move mouse to (${params.x}, ${params.y})`,
|
||||||
|
`await page.mouse.move(${params.x}, ${params.y});`,
|
||||||
|
];
|
||||||
|
const action = () => tab.page.mouse.move(params.x, params.y);
|
||||||
|
return {
|
||||||
|
code,
|
||||||
|
action,
|
||||||
|
captureSnapshot: false,
|
||||||
|
waitForNetwork: false
|
||||||
|
};
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const click = defineTool({
|
||||||
|
capability: 'core',
|
||||||
|
schema: {
|
||||||
|
name: 'browser_screen_click',
|
||||||
|
title: 'Click',
|
||||||
|
description: 'Click left mouse button',
|
||||||
|
inputSchema: elementSchema.extend({
|
||||||
|
x: z.number().describe('X coordinate'),
|
||||||
|
y: z.number().describe('Y coordinate'),
|
||||||
|
}),
|
||||||
|
type: 'destructive',
|
||||||
|
},
|
||||||
|
|
||||||
|
handle: async (context, params) => {
|
||||||
|
const tab = context.currentTabOrDie();
|
||||||
|
const code = [
|
||||||
|
`// Click mouse at coordinates (${params.x}, ${params.y})`,
|
||||||
|
`await page.mouse.move(${params.x}, ${params.y});`,
|
||||||
|
`await page.mouse.down();`,
|
||||||
|
`await page.mouse.up();`,
|
||||||
|
];
|
||||||
|
const action = async () => {
|
||||||
|
await tab.page.mouse.move(params.x, params.y);
|
||||||
|
await tab.page.mouse.down();
|
||||||
|
await tab.page.mouse.up();
|
||||||
|
};
|
||||||
|
return {
|
||||||
|
code,
|
||||||
|
action,
|
||||||
|
captureSnapshot: false,
|
||||||
|
waitForNetwork: true,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const drag = defineTool({
|
||||||
|
capability: 'core',
|
||||||
|
schema: {
|
||||||
|
name: 'browser_screen_drag',
|
||||||
|
title: 'Drag mouse',
|
||||||
|
description: 'Drag left mouse button',
|
||||||
|
inputSchema: elementSchema.extend({
|
||||||
|
startX: z.number().describe('Start X coordinate'),
|
||||||
|
startY: z.number().describe('Start Y coordinate'),
|
||||||
|
endX: z.number().describe('End X coordinate'),
|
||||||
|
endY: z.number().describe('End Y coordinate'),
|
||||||
|
}),
|
||||||
|
type: 'destructive',
|
||||||
|
},
|
||||||
|
|
||||||
|
handle: async (context, params) => {
|
||||||
|
const tab = context.currentTabOrDie();
|
||||||
|
|
||||||
|
const code = [
|
||||||
|
`// Drag mouse from (${params.startX}, ${params.startY}) to (${params.endX}, ${params.endY})`,
|
||||||
|
`await page.mouse.move(${params.startX}, ${params.startY});`,
|
||||||
|
`await page.mouse.down();`,
|
||||||
|
`await page.mouse.move(${params.endX}, ${params.endY});`,
|
||||||
|
`await page.mouse.up();`,
|
||||||
|
];
|
||||||
|
|
||||||
|
const action = async () => {
|
||||||
|
await tab.page.mouse.move(params.startX, params.startY);
|
||||||
|
await tab.page.mouse.down();
|
||||||
|
await tab.page.mouse.move(params.endX, params.endY);
|
||||||
|
await tab.page.mouse.up();
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
code,
|
||||||
|
action,
|
||||||
|
captureSnapshot: false,
|
||||||
|
waitForNetwork: true,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const type = defineTool({
|
||||||
|
capability: 'core',
|
||||||
|
schema: {
|
||||||
|
name: 'browser_screen_type',
|
||||||
|
title: 'Type text',
|
||||||
|
description: 'Type text',
|
||||||
|
inputSchema: z.object({
|
||||||
|
text: z.string().describe('Text to type into the element'),
|
||||||
|
submit: z.boolean().optional().describe('Whether to submit entered text (press Enter after)'),
|
||||||
|
}),
|
||||||
|
type: 'destructive',
|
||||||
|
},
|
||||||
|
|
||||||
|
handle: async (context, params) => {
|
||||||
|
const tab = context.currentTabOrDie();
|
||||||
|
|
||||||
|
const code = [
|
||||||
|
`// Type ${params.text}`,
|
||||||
|
`await page.keyboard.type('${params.text}');`,
|
||||||
|
];
|
||||||
|
|
||||||
|
const action = async () => {
|
||||||
|
await tab.page.keyboard.type(params.text);
|
||||||
|
if (params.submit)
|
||||||
|
await tab.page.keyboard.press('Enter');
|
||||||
|
};
|
||||||
|
|
||||||
|
if (params.submit) {
|
||||||
|
code.push(`// Submit text`);
|
||||||
|
code.push(`await page.keyboard.press('Enter');`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
code,
|
||||||
|
action,
|
||||||
|
captureSnapshot: false,
|
||||||
|
waitForNetwork: true,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export default [
|
||||||
|
screenshot,
|
||||||
|
moveMouse,
|
||||||
|
click,
|
||||||
|
drag,
|
||||||
|
type,
|
||||||
|
];
|
||||||
70
src/tools/wait.ts
Normal file
70
src/tools/wait.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 { z } from 'zod';
|
||||||
|
import { defineTool, type ToolFactory } from './tool.js';
|
||||||
|
|
||||||
|
const wait: ToolFactory = captureSnapshot => defineTool({
|
||||||
|
capability: 'wait',
|
||||||
|
|
||||||
|
schema: {
|
||||||
|
name: 'browser_wait_for',
|
||||||
|
title: 'Wait for',
|
||||||
|
description: 'Wait for text to appear or disappear or a specified time to pass',
|
||||||
|
inputSchema: z.object({
|
||||||
|
time: z.number().optional().describe('The time to wait in seconds'),
|
||||||
|
text: z.string().optional().describe('The text to wait for'),
|
||||||
|
textGone: z.string().optional().describe('The text to wait for to disappear'),
|
||||||
|
}),
|
||||||
|
type: 'readOnly',
|
||||||
|
},
|
||||||
|
|
||||||
|
handle: async (context, params) => {
|
||||||
|
if (!params.text && !params.textGone && !params.time)
|
||||||
|
throw new Error('Either time, text or textGone must be provided');
|
||||||
|
|
||||||
|
const code: string[] = [];
|
||||||
|
|
||||||
|
if (params.time) {
|
||||||
|
code.push(`await new Promise(f => setTimeout(f, ${params.time!} * 1000));`);
|
||||||
|
await new Promise(f => setTimeout(f, Math.min(10000, params.time! * 1000)));
|
||||||
|
}
|
||||||
|
|
||||||
|
const tab = context.currentTabOrDie();
|
||||||
|
const locator = params.text ? tab.page.getByText(params.text).first() : undefined;
|
||||||
|
const goneLocator = params.textGone ? tab.page.getByText(params.textGone).first() : undefined;
|
||||||
|
|
||||||
|
if (goneLocator) {
|
||||||
|
code.push(`await page.getByText(${JSON.stringify(params.textGone)}).first().waitFor({ state: 'hidden' });`);
|
||||||
|
await goneLocator.waitFor({ state: 'hidden' });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (locator) {
|
||||||
|
code.push(`await page.getByText(${JSON.stringify(params.text)}).first().waitFor({ state: 'visible' });`);
|
||||||
|
await locator.waitFor({ state: 'visible' });
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
code,
|
||||||
|
captureSnapshot,
|
||||||
|
waitForNetwork: false,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export default (captureSnapshot: boolean) => [
|
||||||
|
wait(captureSnapshot),
|
||||||
|
];
|
||||||
149
src/transport.ts
Normal file
149
src/transport.ts
Normal file
@@ -0,0 +1,149 @@
|
|||||||
|
/**
|
||||||
|
* Copyright (c) Microsoft Corporation.
|
||||||
|
*
|
||||||
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
* you may not use this file except in compliance with the License.
|
||||||
|
* You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing, software
|
||||||
|
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
* See the License for the specific language governing permissions and
|
||||||
|
* limitations under the License.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import http from 'node:http';
|
||||||
|
import assert from 'node:assert';
|
||||||
|
import crypto from 'node:crypto';
|
||||||
|
|
||||||
|
import debug from 'debug';
|
||||||
|
import { SSEServerTransport } from '@modelcontextprotocol/sdk/server/sse.js';
|
||||||
|
import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
|
||||||
|
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
||||||
|
|
||||||
|
import type { AddressInfo } from 'node:net';
|
||||||
|
import type { Server } from './server.js';
|
||||||
|
|
||||||
|
export async function startStdioTransport(server: Server) {
|
||||||
|
await server.createConnection(new StdioServerTransport());
|
||||||
|
}
|
||||||
|
|
||||||
|
const testDebug = debug('pw:mcp:test');
|
||||||
|
|
||||||
|
async function handleSSE(server: Server, req: http.IncomingMessage, res: http.ServerResponse, url: URL, sessions: Map<string, SSEServerTransport>) {
|
||||||
|
if (req.method === 'POST') {
|
||||||
|
const sessionId = url.searchParams.get('sessionId');
|
||||||
|
if (!sessionId) {
|
||||||
|
res.statusCode = 400;
|
||||||
|
return res.end('Missing sessionId');
|
||||||
|
}
|
||||||
|
|
||||||
|
const transport = sessions.get(sessionId);
|
||||||
|
if (!transport) {
|
||||||
|
res.statusCode = 404;
|
||||||
|
return res.end('Session not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
return await transport.handlePostMessage(req, res);
|
||||||
|
} else if (req.method === 'GET') {
|
||||||
|
const transport = new SSEServerTransport('/sse', res);
|
||||||
|
sessions.set(transport.sessionId, transport);
|
||||||
|
testDebug(`create SSE session: ${transport.sessionId}`);
|
||||||
|
const connection = await server.createConnection(transport);
|
||||||
|
res.on('close', () => {
|
||||||
|
testDebug(`delete SSE session: ${transport.sessionId}`);
|
||||||
|
sessions.delete(transport.sessionId);
|
||||||
|
// eslint-disable-next-line no-console
|
||||||
|
void connection.close().catch(e => console.error(e));
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
res.statusCode = 405;
|
||||||
|
res.end('Method not allowed');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleStreamable(server: Server, req: http.IncomingMessage, res: http.ServerResponse, sessions: Map<string, StreamableHTTPServerTransport>) {
|
||||||
|
const sessionId = req.headers['mcp-session-id'] as string | undefined;
|
||||||
|
if (sessionId) {
|
||||||
|
const transport = sessions.get(sessionId);
|
||||||
|
if (!transport) {
|
||||||
|
res.statusCode = 404;
|
||||||
|
res.end('Session not found');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
return await transport.handleRequest(req, res);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (req.method === 'POST') {
|
||||||
|
const transport = new StreamableHTTPServerTransport({
|
||||||
|
sessionIdGenerator: () => crypto.randomUUID(),
|
||||||
|
onsessioninitialized: sessionId => {
|
||||||
|
sessions.set(sessionId, transport);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
transport.onclose = () => {
|
||||||
|
if (transport.sessionId)
|
||||||
|
sessions.delete(transport.sessionId);
|
||||||
|
};
|
||||||
|
await server.createConnection(transport);
|
||||||
|
await transport.handleRequest(req, res);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
res.statusCode = 400;
|
||||||
|
res.end('Invalid request');
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function startHttpServer(config: { host?: string, port?: number }): Promise<http.Server> {
|
||||||
|
const { host, port } = config;
|
||||||
|
const httpServer = http.createServer();
|
||||||
|
await new Promise<void>((resolve, reject) => {
|
||||||
|
httpServer.on('error', reject);
|
||||||
|
httpServer.listen(port, host, () => {
|
||||||
|
resolve();
|
||||||
|
httpServer.removeListener('error', reject);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
return httpServer;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function startHttpTransport(httpServer: http.Server, mcpServer: Server) {
|
||||||
|
const sseSessions = new Map<string, SSEServerTransport>();
|
||||||
|
const streamableSessions = new Map<string, StreamableHTTPServerTransport>();
|
||||||
|
httpServer.on('request', async (req, res) => {
|
||||||
|
const url = new URL(`http://localhost${req.url}`);
|
||||||
|
if (url.pathname.startsWith('/mcp'))
|
||||||
|
await handleStreamable(mcpServer, req, res, streamableSessions);
|
||||||
|
else
|
||||||
|
await handleSSE(mcpServer, req, res, url, sseSessions);
|
||||||
|
});
|
||||||
|
const url = httpAddressToString(httpServer.address());
|
||||||
|
const message = [
|
||||||
|
`Listening on ${url}`,
|
||||||
|
'Put this in your client config:',
|
||||||
|
JSON.stringify({
|
||||||
|
'mcpServers': {
|
||||||
|
'playwright': {
|
||||||
|
'url': `${url}/sse`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, undefined, 2),
|
||||||
|
'If your client supports streamable HTTP, you can use the /mcp endpoint instead.',
|
||||||
|
].join('\n');
|
||||||
|
// eslint-disable-next-line no-console
|
||||||
|
console.error(message);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function httpAddressToString(address: string | AddressInfo | null): string {
|
||||||
|
assert(address, 'Could not bind server socket');
|
||||||
|
if (typeof address === 'string')
|
||||||
|
return address;
|
||||||
|
const resolvedPort = address.port;
|
||||||
|
let resolvedHost = address.family === 'IPv4' ? address.address : `[${address.address}]`;
|
||||||
|
if (resolvedHost === '0.0.0.0' || resolvedHost === '[::]')
|
||||||
|
resolvedHost = 'localhost';
|
||||||
|
return `http://${resolvedHost}:${resolvedPort}`;
|
||||||
|
}
|
||||||
@@ -1,370 +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/promises';
|
|
||||||
import { spawn } from 'node:child_process';
|
|
||||||
import path from 'node:path';
|
|
||||||
import { test, expect } from './fixtures';
|
|
||||||
|
|
||||||
test('test tool list', async ({ client, visionClient }) => {
|
|
||||||
const { tools } = await client.listTools();
|
|
||||||
expect(tools.map(t => t.name)).toEqual([
|
|
||||||
'browser_navigate',
|
|
||||||
'browser_go_back',
|
|
||||||
'browser_go_forward',
|
|
||||||
'browser_choose_file',
|
|
||||||
'browser_snapshot',
|
|
||||||
'browser_click',
|
|
||||||
'browser_hover',
|
|
||||||
'browser_type',
|
|
||||||
'browser_select_option',
|
|
||||||
'browser_take_screenshot',
|
|
||||||
'browser_press_key',
|
|
||||||
'browser_wait',
|
|
||||||
'browser_save_as_pdf',
|
|
||||||
'browser_close',
|
|
||||||
'browser_install',
|
|
||||||
]);
|
|
||||||
|
|
||||||
const { tools: visionTools } = await visionClient.listTools();
|
|
||||||
expect(visionTools.map(t => t.name)).toEqual([
|
|
||||||
'browser_navigate',
|
|
||||||
'browser_go_back',
|
|
||||||
'browser_go_forward',
|
|
||||||
'browser_choose_file',
|
|
||||||
'browser_screenshot',
|
|
||||||
'browser_move_mouse',
|
|
||||||
'browser_click',
|
|
||||||
'browser_drag',
|
|
||||||
'browser_type',
|
|
||||||
'browser_press_key',
|
|
||||||
'browser_wait',
|
|
||||||
'browser_save_as_pdf',
|
|
||||||
'browser_close',
|
|
||||||
'browser_install',
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('test resources list', async ({ client }) => {
|
|
||||||
const { resources } = await client.listResources();
|
|
||||||
expect(resources).toEqual([
|
|
||||||
expect.objectContaining({
|
|
||||||
uri: 'browser://console',
|
|
||||||
mimeType: 'text/plain',
|
|
||||||
}),
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('test browser_navigate', async ({ client }) => {
|
|
||||||
expect(await client.callTool({
|
|
||||||
name: 'browser_navigate',
|
|
||||||
arguments: {
|
|
||||||
url: 'data:text/html,<html><title>Title</title><body>Hello, world!</body></html>',
|
|
||||||
},
|
|
||||||
})).toHaveTextContent(`
|
|
||||||
- Page URL: data:text/html,<html><title>Title</title><body>Hello, world!</body></html>
|
|
||||||
- Page Title: Title
|
|
||||||
- Page Snapshot
|
|
||||||
\`\`\`yaml
|
|
||||||
- text: Hello, world!
|
|
||||||
\`\`\`
|
|
||||||
`
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('test browser_click', async ({ client }) => {
|
|
||||||
await client.callTool({
|
|
||||||
name: 'browser_navigate',
|
|
||||||
arguments: {
|
|
||||||
url: 'data:text/html,<html><title>Title</title><button>Submit</button></html>',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(await client.callTool({
|
|
||||||
name: 'browser_click',
|
|
||||||
arguments: {
|
|
||||||
element: 'Submit button',
|
|
||||||
ref: 's1e3',
|
|
||||||
},
|
|
||||||
})).toHaveTextContent(`"Submit button" clicked
|
|
||||||
|
|
||||||
- Page URL: data:text/html,<html><title>Title</title><button>Submit</button></html>
|
|
||||||
- Page Title: Title
|
|
||||||
- Page Snapshot
|
|
||||||
\`\`\`yaml
|
|
||||||
- button "Submit" [ref=s2e3]
|
|
||||||
\`\`\`
|
|
||||||
`);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('test reopen browser', async ({ client }) => {
|
|
||||||
await client.callTool({
|
|
||||||
name: 'browser_navigate',
|
|
||||||
arguments: {
|
|
||||||
url: 'data:text/html,<html><title>Title</title><body>Hello, world!</body></html>',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(await client.callTool({
|
|
||||||
name: 'browser_close',
|
|
||||||
})).toHaveTextContent('Page closed');
|
|
||||||
|
|
||||||
expect(await client.callTool({
|
|
||||||
name: 'browser_navigate',
|
|
||||||
arguments: {
|
|
||||||
url: 'data:text/html,<html><title>Title</title><body>Hello, world!</body></html>',
|
|
||||||
},
|
|
||||||
})).toHaveTextContent(`
|
|
||||||
- Page URL: data:text/html,<html><title>Title</title><body>Hello, world!</body></html>
|
|
||||||
- Page Title: Title
|
|
||||||
- Page Snapshot
|
|
||||||
\`\`\`yaml
|
|
||||||
- text: Hello, world!
|
|
||||||
\`\`\`
|
|
||||||
`);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('single option', async ({ client }) => {
|
|
||||||
await client.callTool({
|
|
||||||
name: 'browser_navigate',
|
|
||||||
arguments: {
|
|
||||||
url: 'data:text/html,<html><title>Title</title><select><option value="foo">Foo</option><option value="bar">Bar</option></select></html>',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(await client.callTool({
|
|
||||||
name: 'browser_select_option',
|
|
||||||
arguments: {
|
|
||||||
element: 'Select',
|
|
||||||
ref: 's1e3',
|
|
||||||
values: ['bar'],
|
|
||||||
},
|
|
||||||
})).toHaveTextContent(`Selected option in "Select"
|
|
||||||
|
|
||||||
- Page URL: data:text/html,<html><title>Title</title><select><option value="foo">Foo</option><option value="bar">Bar</option></select></html>
|
|
||||||
- Page Title: Title
|
|
||||||
- Page Snapshot
|
|
||||||
\`\`\`yaml
|
|
||||||
- combobox [ref=s2e3]:
|
|
||||||
- option "Foo" [ref=s2e4]
|
|
||||||
- option "Bar" [selected] [ref=s2e5]
|
|
||||||
\`\`\`
|
|
||||||
`);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('multiple option', async ({ client }) => {
|
|
||||||
await client.callTool({
|
|
||||||
name: 'browser_navigate',
|
|
||||||
arguments: {
|
|
||||||
url: 'data:text/html,<html><title>Title</title><select multiple><option value="foo">Foo</option><option value="bar">Bar</option><option value="baz">Baz</option></select></html>',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(await client.callTool({
|
|
||||||
name: 'browser_select_option',
|
|
||||||
arguments: {
|
|
||||||
element: 'Select',
|
|
||||||
ref: 's1e3',
|
|
||||||
values: ['bar', 'baz'],
|
|
||||||
},
|
|
||||||
})).toHaveTextContent(`Selected option in "Select"
|
|
||||||
|
|
||||||
- Page URL: data:text/html,<html><title>Title</title><select multiple><option value="foo">Foo</option><option value="bar">Bar</option><option value="baz">Baz</option></select></html>
|
|
||||||
- Page Title: Title
|
|
||||||
- Page Snapshot
|
|
||||||
\`\`\`yaml
|
|
||||||
- listbox [ref=s2e3]:
|
|
||||||
- option "Foo" [ref=s2e4]
|
|
||||||
- option "Bar" [selected] [ref=s2e5]
|
|
||||||
- option "Baz" [selected] [ref=s2e6]
|
|
||||||
\`\`\`
|
|
||||||
`);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('browser://console', async ({ client }) => {
|
|
||||||
await client.callTool({
|
|
||||||
name: 'browser_navigate',
|
|
||||||
arguments: {
|
|
||||||
url: 'data:text/html,<html><script>console.log("Hello, world!");console.error("Error"); </script></html>',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const resource = await client.readResource({
|
|
||||||
uri: 'browser://console',
|
|
||||||
});
|
|
||||||
expect(resource.contents).toEqual([{
|
|
||||||
uri: 'browser://console',
|
|
||||||
mimeType: 'text/plain',
|
|
||||||
text: '[LOG] Hello, world!\n[ERROR] Error',
|
|
||||||
}]);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('stitched aria frames', async ({ client }) => {
|
|
||||||
expect(await client.callTool({
|
|
||||||
name: 'browser_navigate',
|
|
||||||
arguments: {
|
|
||||||
url: `data:text/html,<h1>Hello</h1><iframe src="data:text/html,<button>World</button><main><iframe src='data:text/html,<p>Nested</p>'></iframe></main>"></iframe><iframe src="data:text/html,<h1>Should be invisible</h1>" style="display: none;"></iframe>`,
|
|
||||||
},
|
|
||||||
})).toContainTextContent(`
|
|
||||||
\`\`\`yaml
|
|
||||||
- heading "Hello" [level=1] [ref=s1e3]
|
|
||||||
- iframe [ref=s1e4]:
|
|
||||||
- button "World" [ref=f1s1e3]
|
|
||||||
- main [ref=f1s1e4]:
|
|
||||||
- iframe [ref=f1s1e5]:
|
|
||||||
- paragraph [ref=f2s1e3]: Nested
|
|
||||||
\`\`\`
|
|
||||||
`);
|
|
||||||
|
|
||||||
expect(await client.callTool({
|
|
||||||
name: 'browser_click',
|
|
||||||
arguments: {
|
|
||||||
element: 'World',
|
|
||||||
ref: 'f1s1e3',
|
|
||||||
},
|
|
||||||
})).toContainTextContent('"World" clicked');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('browser_choose_file', async ({ client }) => {
|
|
||||||
expect(await client.callTool({
|
|
||||||
name: 'browser_navigate',
|
|
||||||
arguments: {
|
|
||||||
url: 'data:text/html,<html><title>Title</title><input type="file" /><button>Button</button></html>',
|
|
||||||
},
|
|
||||||
})).toContainTextContent('- textbox [ref=s1e3]');
|
|
||||||
|
|
||||||
expect(await client.callTool({
|
|
||||||
name: 'browser_click',
|
|
||||||
arguments: {
|
|
||||||
element: 'Textbox',
|
|
||||||
ref: 's1e3',
|
|
||||||
},
|
|
||||||
})).toContainTextContent('There is a file chooser visible that requires browser_choose_file to be called');
|
|
||||||
|
|
||||||
const filePath = test.info().outputPath('test.txt');
|
|
||||||
await fs.writeFile(filePath, 'Hello, world!');
|
|
||||||
|
|
||||||
{
|
|
||||||
const response = await client.callTool({
|
|
||||||
name: 'browser_choose_file',
|
|
||||||
arguments: {
|
|
||||||
paths: [filePath],
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(response).not.toContainTextContent('There is a file chooser visible that requires browser_choose_file to be called');
|
|
||||||
expect(response).toContainTextContent('textbox [ref=s3e3]: C:\\fakepath\\test.txt');
|
|
||||||
}
|
|
||||||
|
|
||||||
{
|
|
||||||
const response = await client.callTool({
|
|
||||||
name: 'browser_click',
|
|
||||||
arguments: {
|
|
||||||
element: 'Textbox',
|
|
||||||
ref: 's3e3',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(response).toContainTextContent('There is a file chooser visible that requires browser_choose_file to be called');
|
|
||||||
expect(response).toContainTextContent('button "Button" [ref=s4e4]');
|
|
||||||
}
|
|
||||||
|
|
||||||
{
|
|
||||||
const response = await client.callTool({
|
|
||||||
name: 'browser_click',
|
|
||||||
arguments: {
|
|
||||||
element: 'Button',
|
|
||||||
ref: 's4e4',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(response, 'not submitting browser_choose_file dismisses file chooser').not.toContainTextContent('There is a file chooser visible that requires browser_choose_file to be called');
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
test('sse transport', async () => {
|
|
||||||
const cp = spawn('node', [path.join(__dirname, '../cli.js'), '--port', '0'], { stdio: 'pipe' });
|
|
||||||
try {
|
|
||||||
let stdout = '';
|
|
||||||
const url = await new Promise<string>(resolve => cp.stdout?.on('data', data => {
|
|
||||||
stdout += data.toString();
|
|
||||||
const match = stdout.match(/Listening on (http:\/\/.*)/);
|
|
||||||
if (match)
|
|
||||||
resolve(match[1]);
|
|
||||||
}));
|
|
||||||
|
|
||||||
// need dynamic import b/c of some ESM nonsense
|
|
||||||
const { SSEClientTransport } = await import('@modelcontextprotocol/sdk/client/sse.js');
|
|
||||||
const { Client } = await import('@modelcontextprotocol/sdk/client/index.js');
|
|
||||||
const transport = new SSEClientTransport(new URL(url));
|
|
||||||
const client = new Client({ name: 'test', version: '1.0.0' });
|
|
||||||
await client.connect(transport);
|
|
||||||
await client.ping();
|
|
||||||
} finally {
|
|
||||||
cp.kill();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
test('cdp server', async ({ cdpEndpoint, startClient }) => {
|
|
||||||
const client = await startClient({ args: [`--cdp-endpoint=${cdpEndpoint}`] });
|
|
||||||
expect(await client.callTool({
|
|
||||||
name: 'browser_navigate',
|
|
||||||
arguments: {
|
|
||||||
url: 'data:text/html,<html><title>Title</title><body>Hello, world!</body></html>',
|
|
||||||
},
|
|
||||||
})).toHaveTextContent(`
|
|
||||||
- Page URL: data:text/html,<html><title>Title</title><body>Hello, world!</body></html>
|
|
||||||
- Page Title: Title
|
|
||||||
- Page Snapshot
|
|
||||||
\`\`\`yaml
|
|
||||||
- text: Hello, world!
|
|
||||||
\`\`\`
|
|
||||||
`
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('save as pdf', async ({ client }) => {
|
|
||||||
expect(await client.callTool({
|
|
||||||
name: 'browser_navigate',
|
|
||||||
arguments: {
|
|
||||||
url: 'data:text/html,<html><title>Title</title><body>Hello, world!</body></html>',
|
|
||||||
},
|
|
||||||
})).toHaveTextContent(`
|
|
||||||
- Page URL: data:text/html,<html><title>Title</title><body>Hello, world!</body></html>
|
|
||||||
- Page Title: Title
|
|
||||||
- Page Snapshot
|
|
||||||
\`\`\`yaml
|
|
||||||
- text: Hello, world!
|
|
||||||
\`\`\`
|
|
||||||
`
|
|
||||||
);
|
|
||||||
|
|
||||||
const response = await client.callTool({
|
|
||||||
name: 'browser_save_as_pdf',
|
|
||||||
});
|
|
||||||
expect(response).toHaveTextContent(/^Saved as.*page-[^:]+.pdf$/);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('executable path', async ({ startClient }) => {
|
|
||||||
const client = await startClient({ args: [`--executable-path=bogus`] });
|
|
||||||
const response = await client.callTool({
|
|
||||||
name: 'browser_navigate',
|
|
||||||
arguments: {
|
|
||||||
url: 'data:text/html,<html><title>Title</title><body>Hello, world!</body></html>',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
expect(response).toContainTextContent(`executable doesn't exist`);
|
|
||||||
});
|
|
||||||
77
tests/browser-server.spec.ts
Normal file
77
tests/browser-server.spec.ts
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
/**
|
||||||
|
* Copyright (c) Microsoft Corporation.
|
||||||
|
*
|
||||||
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
* you may not use this file except in compliance with the License.
|
||||||
|
* You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing, software
|
||||||
|
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
* See the License for the specific language governing permissions and
|
||||||
|
* limitations under the License.
|
||||||
|
*/
|
||||||
|
import path from 'path';
|
||||||
|
import url from 'node:url';
|
||||||
|
|
||||||
|
import { spawn } from 'child_process';
|
||||||
|
import { test as baseTest, expect } from './fixtures.js';
|
||||||
|
|
||||||
|
import type { ChildProcess } from 'child_process';
|
||||||
|
|
||||||
|
const __filename = url.fileURLToPath(import.meta.url);
|
||||||
|
|
||||||
|
const test = baseTest.extend<{ agentEndpoint: (options?: { args?: string[] }) => Promise<{ url: URL, stdout: () => string }> }>({
|
||||||
|
agentEndpoint: async ({}, use) => {
|
||||||
|
let cp: ChildProcess | undefined;
|
||||||
|
await use(async (options?: { args?: string[] }) => {
|
||||||
|
if (cp)
|
||||||
|
throw new Error('Process already running');
|
||||||
|
|
||||||
|
cp = spawn('node', [
|
||||||
|
path.join(path.dirname(__filename), '../lib/browserServer.js'),
|
||||||
|
...(options?.args || []),
|
||||||
|
], {
|
||||||
|
stdio: 'pipe',
|
||||||
|
env: {
|
||||||
|
...process.env,
|
||||||
|
DEBUG: 'pw:mcp:test',
|
||||||
|
DEBUG_COLORS: '0',
|
||||||
|
DEBUG_HIDE_DATE: '1',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
let stdout = '';
|
||||||
|
const url = await new Promise<string>(resolve => cp!.stdout?.on('data', data => {
|
||||||
|
stdout += data.toString();
|
||||||
|
const match = stdout.match(/Listening on (http:\/\/.*)/);
|
||||||
|
if (match)
|
||||||
|
resolve(match[1]);
|
||||||
|
}));
|
||||||
|
|
||||||
|
return { url: new URL(url), stdout: () => stdout };
|
||||||
|
});
|
||||||
|
cp?.kill('SIGTERM');
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
test.skip(({ mcpBrowser }) => mcpBrowser !== 'chrome', 'Agent is CDP-only for now');
|
||||||
|
|
||||||
|
test('browser lifecycle', async ({ agentEndpoint, startClient, server }) => {
|
||||||
|
const { url: agentUrl } = await agentEndpoint();
|
||||||
|
const { client: client1 } = await startClient({ args: ['--browser-agent', agentUrl.toString()] });
|
||||||
|
expect(await client1.callTool({
|
||||||
|
name: 'browser_navigate',
|
||||||
|
arguments: { url: server.HELLO_WORLD },
|
||||||
|
})).toContainTextContent('Hello, world!');
|
||||||
|
|
||||||
|
const { client: client2 } = await startClient({ args: ['--browser-agent', agentUrl.toString()] });
|
||||||
|
expect(await client2.callTool({
|
||||||
|
name: 'browser_navigate',
|
||||||
|
arguments: { url: server.HELLO_WORLD },
|
||||||
|
})).toContainTextContent('Hello, world!');
|
||||||
|
|
||||||
|
await client1.close();
|
||||||
|
await client2.close();
|
||||||
|
});
|
||||||
92
tests/capabilities.spec.ts
Normal file
92
tests/capabilities.spec.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 { test, expect } from './fixtures.js';
|
||||||
|
|
||||||
|
test('test snapshot tool list', async ({ client }) => {
|
||||||
|
const { tools } = await client.listTools();
|
||||||
|
expect(new Set(tools.map(t => t.name))).toEqual(new Set([
|
||||||
|
'browser_click',
|
||||||
|
'browser_console_messages',
|
||||||
|
'browser_drag',
|
||||||
|
'browser_file_upload',
|
||||||
|
'browser_generate_playwright_test',
|
||||||
|
'browser_handle_dialog',
|
||||||
|
'browser_hover',
|
||||||
|
'browser_select_option',
|
||||||
|
'browser_type',
|
||||||
|
'browser_close',
|
||||||
|
'browser_install',
|
||||||
|
'browser_navigate_back',
|
||||||
|
'browser_navigate_forward',
|
||||||
|
'browser_navigate',
|
||||||
|
'browser_network_requests',
|
||||||
|
'browser_pdf_save',
|
||||||
|
'browser_press_key',
|
||||||
|
'browser_resize',
|
||||||
|
'browser_snapshot',
|
||||||
|
'browser_tab_close',
|
||||||
|
'browser_tab_list',
|
||||||
|
'browser_tab_new',
|
||||||
|
'browser_tab_select',
|
||||||
|
'browser_take_screenshot',
|
||||||
|
'browser_wait_for',
|
||||||
|
]));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('test vision tool list', async ({ visionClient }) => {
|
||||||
|
const { tools: visionTools } = await visionClient.listTools();
|
||||||
|
expect(new Set(visionTools.map(t => t.name))).toEqual(new Set([
|
||||||
|
'browser_close',
|
||||||
|
'browser_console_messages',
|
||||||
|
'browser_file_upload',
|
||||||
|
'browser_generate_playwright_test',
|
||||||
|
'browser_handle_dialog',
|
||||||
|
'browser_install',
|
||||||
|
'browser_navigate_back',
|
||||||
|
'browser_navigate_forward',
|
||||||
|
'browser_navigate',
|
||||||
|
'browser_network_requests',
|
||||||
|
'browser_pdf_save',
|
||||||
|
'browser_press_key',
|
||||||
|
'browser_resize',
|
||||||
|
'browser_screen_capture',
|
||||||
|
'browser_screen_click',
|
||||||
|
'browser_screen_drag',
|
||||||
|
'browser_screen_move_mouse',
|
||||||
|
'browser_screen_type',
|
||||||
|
'browser_tab_close',
|
||||||
|
'browser_tab_list',
|
||||||
|
'browser_tab_new',
|
||||||
|
'browser_tab_select',
|
||||||
|
'browser_wait_for',
|
||||||
|
]));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('test capabilities', async ({ startClient }) => {
|
||||||
|
const { client } = await startClient({
|
||||||
|
args: ['--caps="core"'],
|
||||||
|
});
|
||||||
|
const { tools } = await client.listTools();
|
||||||
|
const toolNames = tools.map(t => t.name);
|
||||||
|
expect(toolNames).not.toContain('browser_file_upload');
|
||||||
|
expect(toolNames).not.toContain('browser_pdf_save');
|
||||||
|
expect(toolNames).not.toContain('browser_screen_capture');
|
||||||
|
expect(toolNames).not.toContain('browser_screen_click');
|
||||||
|
expect(toolNames).not.toContain('browser_screen_drag');
|
||||||
|
expect(toolNames).not.toContain('browser_screen_move_mouse');
|
||||||
|
expect(toolNames).not.toContain('browser_screen_type');
|
||||||
|
});
|
||||||
92
tests/cdp.spec.ts
Normal file
92
tests/cdp.spec.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 url from 'node:url';
|
||||||
|
import path from 'node:path';
|
||||||
|
import { spawnSync } from 'node:child_process';
|
||||||
|
import { test, expect } from './fixtures.js';
|
||||||
|
|
||||||
|
test('cdp server', async ({ cdpServer, startClient, server }) => {
|
||||||
|
await cdpServer.start();
|
||||||
|
const { client } = await startClient({ args: [`--cdp-endpoint=${cdpServer.endpoint}`] });
|
||||||
|
expect(await client.callTool({
|
||||||
|
name: 'browser_navigate',
|
||||||
|
arguments: { url: server.HELLO_WORLD },
|
||||||
|
})).toContainTextContent(`- generic [active] [ref=e1]: Hello, world!`);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('cdp server reuse tab', async ({ cdpServer, startClient, server }) => {
|
||||||
|
const browserContext = await cdpServer.start();
|
||||||
|
const { client } = await startClient({ args: [`--cdp-endpoint=${cdpServer.endpoint}`] });
|
||||||
|
|
||||||
|
const [page] = browserContext.pages();
|
||||||
|
await page.goto(server.HELLO_WORLD);
|
||||||
|
|
||||||
|
expect(await client.callTool({
|
||||||
|
name: 'browser_click',
|
||||||
|
arguments: {
|
||||||
|
element: 'Hello, world!',
|
||||||
|
ref: 'f0',
|
||||||
|
},
|
||||||
|
})).toHaveTextContent(`Error: No current snapshot available. Capture a snapshot or navigate to a new location first.`);
|
||||||
|
|
||||||
|
expect(await client.callTool({
|
||||||
|
name: 'browser_snapshot',
|
||||||
|
})).toHaveTextContent(`
|
||||||
|
- Ran Playwright code:
|
||||||
|
\`\`\`js
|
||||||
|
// <internal code to capture accessibility snapshot>
|
||||||
|
\`\`\`
|
||||||
|
|
||||||
|
- Page URL: ${server.HELLO_WORLD}
|
||||||
|
- Page Title: Title
|
||||||
|
- Page Snapshot
|
||||||
|
\`\`\`yaml
|
||||||
|
- generic [active] [ref=e1]: Hello, world!
|
||||||
|
\`\`\`
|
||||||
|
`);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should throw connection error and allow re-connecting', async ({ cdpServer, startClient, server }) => {
|
||||||
|
const { client } = await startClient({ args: [`--cdp-endpoint=${cdpServer.endpoint}`] });
|
||||||
|
|
||||||
|
server.setContent('/', `
|
||||||
|
<title>Title</title>
|
||||||
|
<body>Hello, world!</body>
|
||||||
|
`, 'text/html');
|
||||||
|
|
||||||
|
expect(await client.callTool({
|
||||||
|
name: 'browser_navigate',
|
||||||
|
arguments: { url: server.PREFIX },
|
||||||
|
})).toContainTextContent(`Error: browserType.connectOverCDP: connect ECONNREFUSED`);
|
||||||
|
await cdpServer.start();
|
||||||
|
expect(await client.callTool({
|
||||||
|
name: 'browser_navigate',
|
||||||
|
arguments: { url: server.PREFIX },
|
||||||
|
})).toContainTextContent(`- generic [active] [ref=e1]: Hello, world!`);
|
||||||
|
});
|
||||||
|
|
||||||
|
// NOTE: Can be removed when we drop Node.js 18 support and changed to import.meta.filename.
|
||||||
|
const __filename = url.fileURLToPath(import.meta.url);
|
||||||
|
|
||||||
|
test('does not support --device', async () => {
|
||||||
|
const result = spawnSync('node', [
|
||||||
|
path.join(__filename, '../../cli.js'), '--device=Pixel 5', '--cdp-endpoint=http://localhost:1234',
|
||||||
|
]);
|
||||||
|
expect(result.error).toBeUndefined();
|
||||||
|
expect(result.status).toBe(1);
|
||||||
|
expect(result.stderr.toString()).toContain('Device emulation is not supported with cdpEndpoint.');
|
||||||
|
});
|
||||||
63
tests/config.spec.ts
Normal file
63
tests/config.spec.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 fs from 'node:fs';
|
||||||
|
|
||||||
|
import { Config } from '../config.js';
|
||||||
|
import { test, expect } from './fixtures.js';
|
||||||
|
|
||||||
|
test('config user data dir', async ({ startClient, server, mcpMode }, testInfo) => {
|
||||||
|
server.setContent('/', `
|
||||||
|
<title>Title</title>
|
||||||
|
<body>Hello, world!</body>
|
||||||
|
`, 'text/html');
|
||||||
|
|
||||||
|
const config: Config = {
|
||||||
|
browser: {
|
||||||
|
userDataDir: testInfo.outputPath('user-data-dir'),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const configPath = testInfo.outputPath('config.json');
|
||||||
|
await fs.promises.writeFile(configPath, JSON.stringify(config, null, 2));
|
||||||
|
|
||||||
|
const { client } = await startClient({ args: ['--config', configPath] });
|
||||||
|
expect(await client.callTool({
|
||||||
|
name: 'browser_navigate',
|
||||||
|
arguments: { url: server.PREFIX },
|
||||||
|
})).toContainTextContent(`Hello, world!`);
|
||||||
|
|
||||||
|
const files = await fs.promises.readdir(config.browser!.userDataDir!);
|
||||||
|
expect(files.length).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test.describe(() => {
|
||||||
|
test.use({ mcpBrowser: '' });
|
||||||
|
test('browserName', { annotation: { type: 'issue', description: 'https://github.com/microsoft/playwright-mcp/issues/458' } }, async ({ startClient, mcpMode }, testInfo) => {
|
||||||
|
const config: Config = {
|
||||||
|
browser: {
|
||||||
|
browserName: 'firefox',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const configPath = testInfo.outputPath('config.json');
|
||||||
|
await fs.promises.writeFile(configPath, JSON.stringify(config, null, 2));
|
||||||
|
|
||||||
|
const { client } = await startClient({ args: ['--config', configPath] });
|
||||||
|
expect(await client.callTool({
|
||||||
|
name: 'browser_navigate',
|
||||||
|
arguments: { url: 'data:text/html,<script>document.title = navigator.userAgent</script>' },
|
||||||
|
})).toContainTextContent(`Firefox`);
|
||||||
|
});
|
||||||
|
});
|
||||||
44
tests/console.spec.ts
Normal file
44
tests/console.spec.ts
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
/**
|
||||||
|
* Copyright (c) Microsoft Corporation.
|
||||||
|
*
|
||||||
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
* you may not use this file except in compliance with the License.
|
||||||
|
* You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing, software
|
||||||
|
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
* See the License for the specific language governing permissions and
|
||||||
|
* limitations under the License.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { test, expect } from './fixtures.js';
|
||||||
|
|
||||||
|
test('browser_console_messages', async ({ client, server }) => {
|
||||||
|
server.setContent('/', `
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<script>
|
||||||
|
console.log("Hello, world!");
|
||||||
|
console.error("Error");
|
||||||
|
</script>
|
||||||
|
</html>
|
||||||
|
`, 'text/html');
|
||||||
|
|
||||||
|
await client.callTool({
|
||||||
|
name: 'browser_navigate',
|
||||||
|
arguments: {
|
||||||
|
url: server.PREFIX,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const resource = await client.callTool({
|
||||||
|
name: 'browser_console_messages',
|
||||||
|
});
|
||||||
|
expect(resource).toHaveTextContent([
|
||||||
|
'[LOG] Hello, world!',
|
||||||
|
'[ERROR] Error',
|
||||||
|
].join('\n'));
|
||||||
|
});
|
||||||
335
tests/core.spec.ts
Normal file
335
tests/core.spec.ts
Normal file
@@ -0,0 +1,335 @@
|
|||||||
|
/**
|
||||||
|
* Copyright (c) Microsoft Corporation.
|
||||||
|
*
|
||||||
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
* you may not use this file except in compliance with the License.
|
||||||
|
* You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing, software
|
||||||
|
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
* See the License for the specific language governing permissions and
|
||||||
|
* limitations under the License.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { test, expect } from './fixtures.js';
|
||||||
|
|
||||||
|
test('browser_navigate', async ({ client, server }) => {
|
||||||
|
expect(await client.callTool({
|
||||||
|
name: 'browser_navigate',
|
||||||
|
arguments: { url: server.HELLO_WORLD },
|
||||||
|
})).toHaveTextContent(`
|
||||||
|
- Ran Playwright code:
|
||||||
|
\`\`\`js
|
||||||
|
// Navigate to ${server.HELLO_WORLD}
|
||||||
|
await page.goto('${server.HELLO_WORLD}');
|
||||||
|
\`\`\`
|
||||||
|
|
||||||
|
- Page URL: ${server.HELLO_WORLD}
|
||||||
|
- Page Title: Title
|
||||||
|
- Page Snapshot
|
||||||
|
\`\`\`yaml
|
||||||
|
- generic [active] [ref=e1]: Hello, world!
|
||||||
|
\`\`\`
|
||||||
|
`
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('browser_click', async ({ client, server, mcpBrowser }) => {
|
||||||
|
server.setContent('/', `
|
||||||
|
<title>Title</title>
|
||||||
|
<button>Submit</button>
|
||||||
|
`, 'text/html');
|
||||||
|
|
||||||
|
await client.callTool({
|
||||||
|
name: 'browser_navigate',
|
||||||
|
arguments: { url: server.PREFIX },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(await client.callTool({
|
||||||
|
name: 'browser_click',
|
||||||
|
arguments: {
|
||||||
|
element: 'Submit button',
|
||||||
|
ref: 'e2',
|
||||||
|
},
|
||||||
|
})).toHaveTextContent(`
|
||||||
|
- Ran Playwright code:
|
||||||
|
\`\`\`js
|
||||||
|
// Click Submit button
|
||||||
|
await page.getByRole('button', { name: 'Submit' }).click();
|
||||||
|
\`\`\`
|
||||||
|
|
||||||
|
- Page URL: ${server.PREFIX}
|
||||||
|
- Page Title: Title
|
||||||
|
- Page Snapshot
|
||||||
|
\`\`\`yaml
|
||||||
|
- button "Submit" ${mcpBrowser !== 'webkit' || process.platform === 'linux' ? '[active] ' : ''}[ref=e2]
|
||||||
|
\`\`\`
|
||||||
|
`);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('browser_click (double)', async ({ client, server }) => {
|
||||||
|
server.setContent('/', `
|
||||||
|
<title>Title</title>
|
||||||
|
<script>
|
||||||
|
function handle() {
|
||||||
|
document.querySelector('h1').textContent = 'Double clicked';
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
<h1 ondblclick="handle()">Click me</h1>
|
||||||
|
`, 'text/html');
|
||||||
|
|
||||||
|
await client.callTool({
|
||||||
|
name: 'browser_navigate',
|
||||||
|
arguments: { url: server.PREFIX },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(await client.callTool({
|
||||||
|
name: 'browser_click',
|
||||||
|
arguments: {
|
||||||
|
element: 'Click me',
|
||||||
|
ref: 'e2',
|
||||||
|
doubleClick: true,
|
||||||
|
},
|
||||||
|
})).toHaveTextContent(`
|
||||||
|
- Ran Playwright code:
|
||||||
|
\`\`\`js
|
||||||
|
// Double click Click me
|
||||||
|
await page.getByRole('heading', { name: 'Click me' }).dblclick();
|
||||||
|
\`\`\`
|
||||||
|
|
||||||
|
- Page URL: ${server.PREFIX}
|
||||||
|
- Page Title: Title
|
||||||
|
- Page Snapshot
|
||||||
|
\`\`\`yaml
|
||||||
|
- heading "Double clicked" [level=1] [ref=e3]
|
||||||
|
\`\`\`
|
||||||
|
`);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('browser_select_option', async ({ client, server }) => {
|
||||||
|
server.setContent('/', `
|
||||||
|
<title>Title</title>
|
||||||
|
<select>
|
||||||
|
<option value="foo">Foo</option>
|
||||||
|
<option value="bar">Bar</option>
|
||||||
|
</select>
|
||||||
|
`, 'text/html');
|
||||||
|
|
||||||
|
await client.callTool({
|
||||||
|
name: 'browser_navigate',
|
||||||
|
arguments: { url: server.PREFIX },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(await client.callTool({
|
||||||
|
name: 'browser_select_option',
|
||||||
|
arguments: {
|
||||||
|
element: 'Select',
|
||||||
|
ref: 'e2',
|
||||||
|
values: ['bar'],
|
||||||
|
},
|
||||||
|
})).toHaveTextContent(`
|
||||||
|
- Ran Playwright code:
|
||||||
|
\`\`\`js
|
||||||
|
// Select options [bar] in Select
|
||||||
|
await page.getByRole('combobox').selectOption(['bar']);
|
||||||
|
\`\`\`
|
||||||
|
|
||||||
|
- Page URL: ${server.PREFIX}
|
||||||
|
- Page Title: Title
|
||||||
|
- Page Snapshot
|
||||||
|
\`\`\`yaml
|
||||||
|
- combobox [ref=e2]:
|
||||||
|
- option "Foo"
|
||||||
|
- option "Bar" [selected]
|
||||||
|
\`\`\`
|
||||||
|
`);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('browser_select_option (multiple)', async ({ client, server }) => {
|
||||||
|
server.setContent('/', `
|
||||||
|
<title>Title</title>
|
||||||
|
<select multiple>
|
||||||
|
<option value="foo">Foo</option>
|
||||||
|
<option value="bar">Bar</option>
|
||||||
|
<option value="baz">Baz</option>
|
||||||
|
</select>
|
||||||
|
`, 'text/html');
|
||||||
|
|
||||||
|
await client.callTool({
|
||||||
|
name: 'browser_navigate',
|
||||||
|
arguments: { url: server.PREFIX },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(await client.callTool({
|
||||||
|
name: 'browser_select_option',
|
||||||
|
arguments: {
|
||||||
|
element: 'Select',
|
||||||
|
ref: 'e2',
|
||||||
|
values: ['bar', 'baz'],
|
||||||
|
},
|
||||||
|
})).toHaveTextContent(`
|
||||||
|
- Ran Playwright code:
|
||||||
|
\`\`\`js
|
||||||
|
// Select options [bar, baz] in Select
|
||||||
|
await page.getByRole('listbox').selectOption(['bar', 'baz']);
|
||||||
|
\`\`\`
|
||||||
|
|
||||||
|
- Page URL: ${server.PREFIX}
|
||||||
|
- Page Title: Title
|
||||||
|
- Page Snapshot
|
||||||
|
\`\`\`yaml
|
||||||
|
- listbox [ref=e2]:
|
||||||
|
- option "Foo" [ref=e3]
|
||||||
|
- option "Bar" [selected] [ref=e4]
|
||||||
|
- option "Baz" [selected] [ref=e5]
|
||||||
|
\`\`\`
|
||||||
|
`);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('browser_type', async ({ client, server }) => {
|
||||||
|
server.setContent('/', `
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<input type='keypress' onkeypress="console.log('Key pressed:', event.key, ', Text:', event.target.value)"></input>
|
||||||
|
</html>
|
||||||
|
`, 'text/html');
|
||||||
|
|
||||||
|
await client.callTool({
|
||||||
|
name: 'browser_navigate',
|
||||||
|
arguments: {
|
||||||
|
url: server.PREFIX,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await client.callTool({
|
||||||
|
name: 'browser_type',
|
||||||
|
arguments: {
|
||||||
|
element: 'textbox',
|
||||||
|
ref: 'e2',
|
||||||
|
text: 'Hi!',
|
||||||
|
submit: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(await client.callTool({
|
||||||
|
name: 'browser_console_messages',
|
||||||
|
})).toHaveTextContent('[LOG] Key pressed: Enter , Text: Hi!');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('browser_type (slowly)', async ({ client, server }) => {
|
||||||
|
server.setContent('/', `
|
||||||
|
<input type='text' onkeydown="console.log('Key pressed:', event.key, 'Text:', event.target.value)"></input>
|
||||||
|
`, 'text/html');
|
||||||
|
|
||||||
|
await client.callTool({
|
||||||
|
name: 'browser_navigate',
|
||||||
|
arguments: {
|
||||||
|
url: server.PREFIX,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await client.callTool({
|
||||||
|
name: 'browser_type',
|
||||||
|
arguments: {
|
||||||
|
element: 'textbox',
|
||||||
|
ref: 'e2',
|
||||||
|
text: 'Hi!',
|
||||||
|
submit: true,
|
||||||
|
slowly: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(await client.callTool({
|
||||||
|
name: 'browser_console_messages',
|
||||||
|
})).toHaveTextContent([
|
||||||
|
'[LOG] Key pressed: H Text: ',
|
||||||
|
'[LOG] Key pressed: i Text: H',
|
||||||
|
'[LOG] Key pressed: ! Text: Hi',
|
||||||
|
'[LOG] Key pressed: Enter Text: Hi!',
|
||||||
|
].join('\n'));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('browser_resize', async ({ client, server }) => {
|
||||||
|
server.setContent('/', `
|
||||||
|
<title>Resize Test</title>
|
||||||
|
<body>
|
||||||
|
<div id="size">Waiting for resize...</div>
|
||||||
|
<script>new ResizeObserver(() => { document.getElementById("size").textContent = \`Window size: \${window.innerWidth}x\${window.innerHeight}\`; }).observe(document.body);
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
`, 'text/html');
|
||||||
|
await client.callTool({
|
||||||
|
name: 'browser_navigate',
|
||||||
|
arguments: { url: server.PREFIX },
|
||||||
|
});
|
||||||
|
|
||||||
|
const response = await client.callTool({
|
||||||
|
name: 'browser_resize',
|
||||||
|
arguments: {
|
||||||
|
width: 390,
|
||||||
|
height: 780,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(response).toContainTextContent(`- Ran Playwright code:
|
||||||
|
\`\`\`js
|
||||||
|
// Resize browser window to 390x780
|
||||||
|
await page.setViewportSize({ width: 390, height: 780 });
|
||||||
|
\`\`\``);
|
||||||
|
await expect.poll(() => client.callTool({ name: 'browser_snapshot' })).toContainTextContent('Window size: 390x780');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('old locator error message', async ({ client, server }) => {
|
||||||
|
server.setContent('/', `
|
||||||
|
<button>Button 1</button>
|
||||||
|
<button>Button 2</button>
|
||||||
|
<script>
|
||||||
|
document.querySelector('button').addEventListener('click', () => {
|
||||||
|
document.querySelectorAll('button')[1].remove();
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
`, 'text/html');
|
||||||
|
|
||||||
|
expect(await client.callTool({
|
||||||
|
name: 'browser_navigate',
|
||||||
|
arguments: {
|
||||||
|
url: server.PREFIX,
|
||||||
|
},
|
||||||
|
})).toContainTextContent(`
|
||||||
|
- button "Button 1" [ref=e2]
|
||||||
|
- button "Button 2" [ref=e3]
|
||||||
|
`.trim());
|
||||||
|
|
||||||
|
await client.callTool({
|
||||||
|
name: 'browser_click',
|
||||||
|
arguments: {
|
||||||
|
element: 'Button 1',
|
||||||
|
ref: 'e2',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(await client.callTool({
|
||||||
|
name: 'browser_click',
|
||||||
|
arguments: {
|
||||||
|
element: 'Button 2',
|
||||||
|
ref: 'e3',
|
||||||
|
},
|
||||||
|
})).toContainTextContent('Ref not found');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('visibility: hidden > visible should be shown', { annotation: { type: 'issue', description: 'https://github.com/microsoft/playwright-mcp/issues/535' } }, async ({ client, server }) => {
|
||||||
|
server.setContent('/', `
|
||||||
|
<div style="visibility: hidden;">
|
||||||
|
<div style="visibility: visible;">
|
||||||
|
<button>Button</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`, 'text/html');
|
||||||
|
|
||||||
|
await client.callTool({
|
||||||
|
name: 'browser_navigate',
|
||||||
|
arguments: { url: server.PREFIX },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(await client.callTool({
|
||||||
|
name: 'browser_snapshot'
|
||||||
|
})).toContainTextContent('- button "Button"');
|
||||||
|
});
|
||||||
43
tests/device.spec.ts
Normal file
43
tests/device.spec.ts
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
/**
|
||||||
|
* Copyright (c) Microsoft Corporation.
|
||||||
|
*
|
||||||
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
* you may not use this file except in compliance with the License.
|
||||||
|
* You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing, software
|
||||||
|
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
* See the License for the specific language governing permissions and
|
||||||
|
* limitations under the License.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { test, expect } from './fixtures.js';
|
||||||
|
|
||||||
|
test('--device should work', async ({ startClient, server, mcpMode }) => {
|
||||||
|
const { client } = await startClient({
|
||||||
|
args: ['--device', 'iPhone 15'],
|
||||||
|
});
|
||||||
|
|
||||||
|
server.route('/', (req, res) => {
|
||||||
|
res.writeHead(200, { 'Content-Type': 'text/html' });
|
||||||
|
res.end(`
|
||||||
|
<head>
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
</head>
|
||||||
|
<body></body>
|
||||||
|
<script>
|
||||||
|
document.body.textContent = window.innerWidth + "x" + window.innerHeight;
|
||||||
|
</script>
|
||||||
|
`);
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(await client.callTool({
|
||||||
|
name: 'browser_navigate',
|
||||||
|
arguments: {
|
||||||
|
url: server.PREFIX,
|
||||||
|
},
|
||||||
|
})).toContainTextContent(`393x659`);
|
||||||
|
});
|
||||||
212
tests/dialogs.spec.ts
Normal file
212
tests/dialogs.spec.ts
Normal file
@@ -0,0 +1,212 @@
|
|||||||
|
/**
|
||||||
|
* Copyright (c) Microsoft Corporation.
|
||||||
|
*
|
||||||
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
* you may not use this file except in compliance with the License.
|
||||||
|
* You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing, software
|
||||||
|
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
* See the License for the specific language governing permissions and
|
||||||
|
* limitations under the License.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { test, expect } from './fixtures.js';
|
||||||
|
|
||||||
|
// https://github.com/microsoft/playwright/issues/35663
|
||||||
|
test.skip(({ mcpBrowser, mcpHeadless }) => mcpBrowser === 'webkit' && mcpHeadless);
|
||||||
|
|
||||||
|
test('alert dialog', async ({ client, server }) => {
|
||||||
|
server.setContent('/', `<button onclick="alert('Alert')">Button</button>`, 'text/html');
|
||||||
|
expect(await client.callTool({
|
||||||
|
name: 'browser_navigate',
|
||||||
|
arguments: { url: server.PREFIX },
|
||||||
|
})).toContainTextContent('- button "Button" [ref=e2]');
|
||||||
|
|
||||||
|
expect(await client.callTool({
|
||||||
|
name: 'browser_click',
|
||||||
|
arguments: {
|
||||||
|
element: 'Button',
|
||||||
|
ref: 'e2',
|
||||||
|
},
|
||||||
|
})).toHaveTextContent(`- Ran Playwright code:
|
||||||
|
\`\`\`js
|
||||||
|
// Click Button
|
||||||
|
await page.getByRole('button', { name: 'Button' }).click();
|
||||||
|
\`\`\`
|
||||||
|
|
||||||
|
### Modal state
|
||||||
|
- ["alert" dialog with message "Alert"]: can be handled by the "browser_handle_dialog" tool`);
|
||||||
|
|
||||||
|
const result = await client.callTool({
|
||||||
|
name: 'browser_handle_dialog',
|
||||||
|
arguments: {
|
||||||
|
accept: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result).not.toContainTextContent('### Modal state');
|
||||||
|
expect(result).toHaveTextContent(`- Ran Playwright code:
|
||||||
|
\`\`\`js
|
||||||
|
// <internal code to handle "alert" dialog>
|
||||||
|
\`\`\`
|
||||||
|
|
||||||
|
- Page URL: ${server.PREFIX}
|
||||||
|
- Page Title:
|
||||||
|
- Page Snapshot
|
||||||
|
\`\`\`yaml
|
||||||
|
- button "Button" [active] [ref=e2]
|
||||||
|
\`\`\`
|
||||||
|
`);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('two alert dialogs', async ({ client, server }) => {
|
||||||
|
test.fixme(true, 'Race between the dialog and ariaSnapshot');
|
||||||
|
|
||||||
|
server.setContent('/', `
|
||||||
|
<title>Title</title>
|
||||||
|
<body>
|
||||||
|
<button onclick="alert('Alert 1');alert('Alert 2');">Button</button>
|
||||||
|
</body>
|
||||||
|
`, 'text/html');
|
||||||
|
|
||||||
|
expect(await client.callTool({
|
||||||
|
name: 'browser_navigate',
|
||||||
|
arguments: { url: server.PREFIX },
|
||||||
|
})).toContainTextContent('- button "Button" [ref=e2]');
|
||||||
|
|
||||||
|
expect(await client.callTool({
|
||||||
|
name: 'browser_click',
|
||||||
|
arguments: {
|
||||||
|
element: 'Button',
|
||||||
|
ref: 'e2',
|
||||||
|
},
|
||||||
|
})).toHaveTextContent(`- Ran Playwright code:
|
||||||
|
\`\`\`js
|
||||||
|
// Click Button
|
||||||
|
await page.getByRole('button', { name: 'Button' }).click();
|
||||||
|
\`\`\`
|
||||||
|
|
||||||
|
### Modal state
|
||||||
|
- ["alert" dialog with message "Alert 1"]: can be handled by the "browser_handle_dialog" tool`);
|
||||||
|
|
||||||
|
const result = await client.callTool({
|
||||||
|
name: 'browser_handle_dialog',
|
||||||
|
arguments: {
|
||||||
|
accept: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result).not.toContainTextContent('### Modal state');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('confirm dialog (true)', async ({ client, server }) => {
|
||||||
|
server.setContent('/', `
|
||||||
|
<title>Title</title>
|
||||||
|
<body>
|
||||||
|
<button onclick="document.body.textContent = confirm('Confirm')">Button</button>
|
||||||
|
</body>
|
||||||
|
`, 'text/html');
|
||||||
|
|
||||||
|
expect(await client.callTool({
|
||||||
|
name: 'browser_navigate',
|
||||||
|
arguments: { url: server.PREFIX },
|
||||||
|
})).toContainTextContent('- button "Button" [ref=e2]');
|
||||||
|
|
||||||
|
expect(await client.callTool({
|
||||||
|
name: 'browser_click',
|
||||||
|
arguments: {
|
||||||
|
element: 'Button',
|
||||||
|
ref: 'e2',
|
||||||
|
},
|
||||||
|
})).toContainTextContent(`### Modal state
|
||||||
|
- ["confirm" dialog with message "Confirm"]: can be handled by the "browser_handle_dialog" tool`);
|
||||||
|
|
||||||
|
const result = await client.callTool({
|
||||||
|
name: 'browser_handle_dialog',
|
||||||
|
arguments: {
|
||||||
|
accept: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result).not.toContainTextContent('### Modal state');
|
||||||
|
expect(result).toContainTextContent('// <internal code to handle "confirm" dialog>');
|
||||||
|
expect(result).toContainTextContent(`- Page Snapshot
|
||||||
|
\`\`\`yaml
|
||||||
|
- generic [active] [ref=e1]: "true"
|
||||||
|
\`\`\``);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('confirm dialog (false)', async ({ client, server }) => {
|
||||||
|
server.setContent('/', `
|
||||||
|
<title>Title</title>
|
||||||
|
<body>
|
||||||
|
<button onclick="document.body.textContent = confirm('Confirm')">Button</button>
|
||||||
|
</body>
|
||||||
|
`, 'text/html');
|
||||||
|
|
||||||
|
expect(await client.callTool({
|
||||||
|
name: 'browser_navigate',
|
||||||
|
arguments: { url: server.PREFIX },
|
||||||
|
})).toContainTextContent('- button "Button" [ref=e2]');
|
||||||
|
|
||||||
|
expect(await client.callTool({
|
||||||
|
name: 'browser_click',
|
||||||
|
arguments: {
|
||||||
|
element: 'Button',
|
||||||
|
ref: 'e2',
|
||||||
|
},
|
||||||
|
})).toContainTextContent(`### Modal state
|
||||||
|
- ["confirm" dialog with message "Confirm"]: can be handled by the "browser_handle_dialog" tool`);
|
||||||
|
|
||||||
|
const result = await client.callTool({
|
||||||
|
name: 'browser_handle_dialog',
|
||||||
|
arguments: {
|
||||||
|
accept: false,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result).toContainTextContent(`- Page Snapshot
|
||||||
|
\`\`\`yaml
|
||||||
|
- generic [active] [ref=e1]: "false"
|
||||||
|
\`\`\``);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('prompt dialog', async ({ client, server }) => {
|
||||||
|
server.setContent('/', `
|
||||||
|
<title>Title</title>
|
||||||
|
<body>
|
||||||
|
<button onclick="document.body.textContent = prompt('Prompt')">Button</button>
|
||||||
|
</body>
|
||||||
|
`, 'text/html');
|
||||||
|
|
||||||
|
expect(await client.callTool({
|
||||||
|
name: 'browser_navigate',
|
||||||
|
arguments: { url: server.PREFIX },
|
||||||
|
})).toContainTextContent('- button "Button" [ref=e2]');
|
||||||
|
|
||||||
|
expect(await client.callTool({
|
||||||
|
name: 'browser_click',
|
||||||
|
arguments: {
|
||||||
|
element: 'Button',
|
||||||
|
ref: 'e2',
|
||||||
|
},
|
||||||
|
})).toContainTextContent(`### Modal state
|
||||||
|
- ["prompt" dialog with message "Prompt"]: can be handled by the "browser_handle_dialog" tool`);
|
||||||
|
|
||||||
|
const result = await client.callTool({
|
||||||
|
name: 'browser_handle_dialog',
|
||||||
|
arguments: {
|
||||||
|
accept: true,
|
||||||
|
promptText: 'Answer',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result).toContainTextContent(`- Page Snapshot
|
||||||
|
\`\`\`yaml
|
||||||
|
- generic [active] [ref=e1]: Answer
|
||||||
|
\`\`\``);
|
||||||
|
});
|
||||||
141
tests/files.spec.ts
Normal file
141
tests/files.spec.ts
Normal file
@@ -0,0 +1,141 @@
|
|||||||
|
/**
|
||||||
|
* Copyright (c) Microsoft Corporation.
|
||||||
|
*
|
||||||
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
* you may not use this file except in compliance with the License.
|
||||||
|
* You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing, software
|
||||||
|
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
* See the License for the specific language governing permissions and
|
||||||
|
* limitations under the License.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { test, expect } from './fixtures.js';
|
||||||
|
import fs from 'fs/promises';
|
||||||
|
|
||||||
|
test('browser_file_upload', async ({ client, server }, testInfo) => {
|
||||||
|
server.setContent('/', `
|
||||||
|
<input type="file" />
|
||||||
|
<button>Button</button>
|
||||||
|
`, 'text/html');
|
||||||
|
|
||||||
|
expect(await client.callTool({
|
||||||
|
name: 'browser_navigate',
|
||||||
|
arguments: { url: server.PREFIX },
|
||||||
|
})).toContainTextContent(`
|
||||||
|
\`\`\`yaml
|
||||||
|
- generic [active] [ref=e1]:
|
||||||
|
- button "Choose File" [ref=e2]
|
||||||
|
- button "Button" [ref=e3]
|
||||||
|
\`\`\``);
|
||||||
|
|
||||||
|
{
|
||||||
|
expect(await client.callTool({
|
||||||
|
name: 'browser_file_upload',
|
||||||
|
arguments: { paths: [] },
|
||||||
|
})).toHaveTextContent(`
|
||||||
|
The tool "browser_file_upload" can only be used when there is related modal state present.
|
||||||
|
### Modal state
|
||||||
|
- There is no modal state present
|
||||||
|
`.trim());
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(await client.callTool({
|
||||||
|
name: 'browser_click',
|
||||||
|
arguments: {
|
||||||
|
element: 'Textbox',
|
||||||
|
ref: 'e2',
|
||||||
|
},
|
||||||
|
})).toContainTextContent(`### Modal state
|
||||||
|
- [File chooser]: can be handled by the "browser_file_upload" tool`);
|
||||||
|
|
||||||
|
const filePath = testInfo.outputPath('test.txt');
|
||||||
|
await fs.writeFile(filePath, 'Hello, world!');
|
||||||
|
|
||||||
|
{
|
||||||
|
const response = await client.callTool({
|
||||||
|
name: 'browser_file_upload',
|
||||||
|
arguments: {
|
||||||
|
paths: [filePath],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response).not.toContainTextContent('### Modal state');
|
||||||
|
}
|
||||||
|
|
||||||
|
{
|
||||||
|
const response = await client.callTool({
|
||||||
|
name: 'browser_click',
|
||||||
|
arguments: {
|
||||||
|
element: 'Textbox',
|
||||||
|
ref: 'e2',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response).toContainTextContent('- [File chooser]: can be handled by the \"browser_file_upload\" tool');
|
||||||
|
}
|
||||||
|
|
||||||
|
{
|
||||||
|
const response = await client.callTool({
|
||||||
|
name: 'browser_click',
|
||||||
|
arguments: {
|
||||||
|
element: 'Button',
|
||||||
|
ref: 'e3',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response).toContainTextContent(`Tool "browser_click" does not handle the modal state.
|
||||||
|
### Modal state
|
||||||
|
- [File chooser]: can be handled by the "browser_file_upload" tool`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('clicking on download link emits download', async ({ startClient, server, mcpMode }, testInfo) => {
|
||||||
|
const { client } = await startClient({
|
||||||
|
config: { outputDir: testInfo.outputPath('output') },
|
||||||
|
});
|
||||||
|
|
||||||
|
server.setContent('/', `<a href="/download" download="test.txt">Download</a>`, 'text/html');
|
||||||
|
server.setContent('/download', 'Data', 'text/plain');
|
||||||
|
|
||||||
|
expect(await client.callTool({
|
||||||
|
name: 'browser_navigate',
|
||||||
|
arguments: { url: server.PREFIX },
|
||||||
|
})).toContainTextContent('- link "Download" [ref=e2]');
|
||||||
|
await client.callTool({
|
||||||
|
name: 'browser_click',
|
||||||
|
arguments: {
|
||||||
|
element: 'Download link',
|
||||||
|
ref: 'e2',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await expect.poll(() => client.callTool({ name: 'browser_snapshot' })).toContainTextContent(`
|
||||||
|
### Downloads
|
||||||
|
- Downloaded file test.txt to ${testInfo.outputPath('output', 'test.txt')}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('navigating to download link emits download', async ({ startClient, server, mcpBrowser, mcpMode }, testInfo) => {
|
||||||
|
const { client } = await startClient({
|
||||||
|
config: { outputDir: testInfo.outputPath('output') },
|
||||||
|
});
|
||||||
|
|
||||||
|
test.skip(mcpBrowser === 'webkit' && process.platform === 'linux', 'https://github.com/microsoft/playwright/blob/8e08fdb52c27bb75de9bf87627bf740fadab2122/tests/library/download.spec.ts#L436');
|
||||||
|
server.route('/download', (req, res) => {
|
||||||
|
res.writeHead(200, {
|
||||||
|
'Content-Type': 'text/plain',
|
||||||
|
'Content-Disposition': 'attachment; filename=test.txt',
|
||||||
|
});
|
||||||
|
res.end('Hello world!');
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(await client.callTool({
|
||||||
|
name: 'browser_navigate',
|
||||||
|
arguments: {
|
||||||
|
url: server.PREFIX + 'download',
|
||||||
|
},
|
||||||
|
})).toContainTextContent('### Downloads');
|
||||||
|
});
|
||||||
@@ -14,49 +14,92 @@
|
|||||||
* limitations under the License.
|
* limitations under the License.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import fs from 'fs';
|
||||||
|
import url from 'url';
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
import { chromium } from 'playwright';
|
import { chromium } from 'playwright';
|
||||||
|
|
||||||
import { test as baseTest, expect as baseExpect } from '@playwright/test';
|
import { test as baseTest, expect as baseExpect } from '@playwright/test';
|
||||||
import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js';
|
import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js';
|
||||||
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
|
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
|
||||||
|
import { TestServer } from './testserver/index.ts';
|
||||||
|
|
||||||
type Fixtures = {
|
import type { Config } from '../config';
|
||||||
client: Client;
|
import type { BrowserContext } from 'playwright';
|
||||||
visionClient: Client;
|
import type { Transport } from '@modelcontextprotocol/sdk/shared/transport.js';
|
||||||
startClient: (options?: { args?: string[], vision?: boolean }) => Promise<Client>;
|
import type { Stream } from 'stream';
|
||||||
wsEndpoint: string;
|
|
||||||
cdpEndpoint: string;
|
export type TestOptions = {
|
||||||
|
mcpBrowser: string | undefined;
|
||||||
|
mcpMode: 'docker' | undefined;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const test = baseTest.extend<Fixtures>({
|
type CDPServer = {
|
||||||
|
endpoint: string;
|
||||||
|
start: () => Promise<BrowserContext>;
|
||||||
|
};
|
||||||
|
|
||||||
|
type TestFixtures = {
|
||||||
|
client: Client;
|
||||||
|
visionClient: Client;
|
||||||
|
startClient: (options?: { clientName?: string, args?: string[], config?: Config }) => Promise<{ client: Client, stderr: () => string }>;
|
||||||
|
wsEndpoint: string;
|
||||||
|
cdpServer: CDPServer;
|
||||||
|
server: TestServer;
|
||||||
|
httpsServer: TestServer;
|
||||||
|
mcpHeadless: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
type WorkerFixtures = {
|
||||||
|
_workerServers: { server: TestServer, httpsServer: TestServer };
|
||||||
|
};
|
||||||
|
|
||||||
|
export const test = baseTest.extend<TestFixtures & TestOptions, WorkerFixtures>({
|
||||||
|
|
||||||
client: async ({ startClient }, use) => {
|
client: async ({ startClient }, use) => {
|
||||||
await use(await startClient());
|
const { client } = await startClient();
|
||||||
|
await use(client);
|
||||||
},
|
},
|
||||||
|
|
||||||
visionClient: async ({ startClient }, use) => {
|
visionClient: async ({ startClient }, use) => {
|
||||||
await use(await startClient({ vision: true }));
|
const { client } = await startClient({ args: ['--vision'] });
|
||||||
|
await use(client);
|
||||||
},
|
},
|
||||||
|
|
||||||
startClient: async ({ }, use, testInfo) => {
|
startClient: async ({ mcpHeadless, mcpBrowser, mcpMode }, use, testInfo) => {
|
||||||
const userDataDir = testInfo.outputPath('user-data-dir');
|
const userDataDir = mcpMode !== 'docker' ? testInfo.outputPath('user-data-dir') : undefined;
|
||||||
let client: StdioClientTransport | undefined;
|
const configDir = path.dirname(test.info().config.configFile!);
|
||||||
|
let client: Client | undefined;
|
||||||
|
|
||||||
use(async options => {
|
await use(async options => {
|
||||||
const args = ['--headless', '--user-data-dir', userDataDir];
|
const args: string[] = [];
|
||||||
if (options?.vision)
|
if (userDataDir)
|
||||||
args.push('--vision');
|
args.push('--user-data-dir', userDataDir);
|
||||||
|
if (process.env.CI && process.platform === 'linux')
|
||||||
|
args.push('--no-sandbox');
|
||||||
|
if (mcpHeadless)
|
||||||
|
args.push('--headless');
|
||||||
|
if (mcpBrowser)
|
||||||
|
args.push(`--browser=${mcpBrowser}`);
|
||||||
if (options?.args)
|
if (options?.args)
|
||||||
args.push(...options.args);
|
args.push(...options.args);
|
||||||
const transport = new StdioClientTransport({
|
if (options?.config) {
|
||||||
command: 'node',
|
const configFile = testInfo.outputPath('config.json');
|
||||||
args: [path.join(__dirname, '../cli.js'), ...args],
|
await fs.promises.writeFile(configFile, JSON.stringify(options.config, null, 2));
|
||||||
|
args.push(`--config=${path.relative(configDir, configFile)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
client = new Client({ name: options?.clientName ?? 'test', version: '1.0.0' });
|
||||||
|
const { transport, stderr } = await createTransport(args, mcpMode);
|
||||||
|
let stderrBuffer = '';
|
||||||
|
stderr?.on('data', data => {
|
||||||
|
if (process.env.PWMCP_DEBUG)
|
||||||
|
process.stderr.write(data);
|
||||||
|
stderrBuffer += data.toString();
|
||||||
});
|
});
|
||||||
const client = new Client({ name: 'test', version: '1.0.0' });
|
|
||||||
await client.connect(transport);
|
await client.connect(transport);
|
||||||
await client.ping();
|
await client.ping();
|
||||||
return client;
|
return { client, stderr: () => stderrBuffer };
|
||||||
});
|
});
|
||||||
|
|
||||||
await client?.close();
|
await client?.close();
|
||||||
@@ -68,17 +111,97 @@ export const test = baseTest.extend<Fixtures>({
|
|||||||
await browserServer.close();
|
await browserServer.close();
|
||||||
},
|
},
|
||||||
|
|
||||||
cdpEndpoint: async ({ }, use, testInfo) => {
|
cdpServer: async ({ mcpBrowser }, use, testInfo) => {
|
||||||
const port = 3200 + (+process.env.TEST_PARALLEL_INDEX!);
|
test.skip(!['chrome', 'msedge', 'chromium'].includes(mcpBrowser!), 'CDP is not supported for non-Chromium browsers');
|
||||||
const browser = await chromium.launchPersistentContext(testInfo.outputPath('user-data-dir'), {
|
|
||||||
channel: 'chrome',
|
let browserContext: BrowserContext | undefined;
|
||||||
args: [`--remote-debugging-port=${port}`],
|
const port = 3200 + test.info().parallelIndex;
|
||||||
|
await use({
|
||||||
|
endpoint: `http://localhost:${port}`,
|
||||||
|
start: async () => {
|
||||||
|
browserContext = await chromium.launchPersistentContext(testInfo.outputPath('cdp-user-data-dir'), {
|
||||||
|
channel: mcpBrowser,
|
||||||
|
headless: true,
|
||||||
|
args: [
|
||||||
|
`--remote-debugging-port=${port}`,
|
||||||
|
],
|
||||||
|
});
|
||||||
|
return browserContext;
|
||||||
|
}
|
||||||
});
|
});
|
||||||
await use(`http://localhost:${port}`);
|
await browserContext?.close();
|
||||||
await browser.close();
|
},
|
||||||
|
|
||||||
|
mcpHeadless: async ({ headless }, use) => {
|
||||||
|
await use(headless);
|
||||||
|
},
|
||||||
|
|
||||||
|
mcpBrowser: ['chrome', { option: true }],
|
||||||
|
|
||||||
|
mcpMode: [undefined, { option: true }],
|
||||||
|
|
||||||
|
_workerServers: [async ({ }, use, workerInfo) => {
|
||||||
|
const port = 8907 + workerInfo.workerIndex * 4;
|
||||||
|
const server = await TestServer.create(port);
|
||||||
|
|
||||||
|
const httpsPort = port + 1;
|
||||||
|
const httpsServer = await TestServer.createHTTPS(httpsPort);
|
||||||
|
|
||||||
|
await use({ server, httpsServer });
|
||||||
|
|
||||||
|
await Promise.all([
|
||||||
|
server.stop(),
|
||||||
|
httpsServer.stop(),
|
||||||
|
]);
|
||||||
|
}, { scope: 'worker' }],
|
||||||
|
|
||||||
|
server: async ({ _workerServers }, use) => {
|
||||||
|
_workerServers.server.reset();
|
||||||
|
await use(_workerServers.server);
|
||||||
|
},
|
||||||
|
|
||||||
|
httpsServer: async ({ _workerServers }, use) => {
|
||||||
|
_workerServers.httpsServer.reset();
|
||||||
|
await use(_workerServers.httpsServer);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
async function createTransport(args: string[], mcpMode: TestOptions['mcpMode']): Promise<{
|
||||||
|
transport: Transport,
|
||||||
|
stderr: Stream | null,
|
||||||
|
}> {
|
||||||
|
// NOTE: Can be removed when we drop Node.js 18 support and changed to import.meta.filename.
|
||||||
|
const __filename = url.fileURLToPath(import.meta.url);
|
||||||
|
if (mcpMode === 'docker') {
|
||||||
|
const dockerArgs = ['run', '--rm', '-i', '--network=host', '-v', `${test.info().project.outputDir}:/app/test-results`];
|
||||||
|
const transport = new StdioClientTransport({
|
||||||
|
command: 'docker',
|
||||||
|
args: [...dockerArgs, 'playwright-mcp-dev:latest', ...args],
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
transport,
|
||||||
|
stderr: transport.stderr,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const transport = new StdioClientTransport({
|
||||||
|
command: 'node',
|
||||||
|
args: [path.join(path.dirname(__filename), '../cli.js'), ...args],
|
||||||
|
cwd: path.join(path.dirname(__filename), '..'),
|
||||||
|
stderr: 'pipe',
|
||||||
|
env: {
|
||||||
|
...process.env,
|
||||||
|
DEBUG: 'pw:mcp:test',
|
||||||
|
DEBUG_COLORS: '0',
|
||||||
|
DEBUG_HIDE_DATE: '1',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
transport,
|
||||||
|
stderr: transport.stderr!,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
type Response = Awaited<ReturnType<Client['callTool']>>;
|
type Response = Awaited<ReturnType<Client['callTool']>>;
|
||||||
|
|
||||||
export const expect = baseExpect.extend({
|
export const expect = baseExpect.extend({
|
||||||
@@ -86,10 +209,17 @@ export const expect = baseExpect.extend({
|
|||||||
const isNot = this.isNot;
|
const isNot = this.isNot;
|
||||||
try {
|
try {
|
||||||
const text = (response.content as any)[0].text;
|
const text = (response.content as any)[0].text;
|
||||||
if (isNot)
|
if (typeof content === 'string') {
|
||||||
baseExpect(text).not.toMatch(content);
|
if (isNot)
|
||||||
else
|
baseExpect(text.trim()).not.toBe(content.trim());
|
||||||
baseExpect(text).toMatch(content);
|
else
|
||||||
|
baseExpect(text.trim()).toBe(content.trim());
|
||||||
|
} else {
|
||||||
|
if (isNot)
|
||||||
|
baseExpect(text).not.toMatch(content);
|
||||||
|
else
|
||||||
|
baseExpect(text).toMatch(content);
|
||||||
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
return {
|
return {
|
||||||
pass: isNot,
|
pass: isNot,
|
||||||
@@ -125,3 +255,7 @@ export const expect = baseExpect.extend({
|
|||||||
};
|
};
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export function formatOutput(output: string): string[] {
|
||||||
|
return output.split('\n').map(line => line.replace(/^pw:mcp:test /, '').replace(/user data dir.*/, 'user data dir').trim()).filter(Boolean);
|
||||||
|
}
|
||||||
|
|||||||
50
tests/headed.spec.ts
Normal file
50
tests/headed.spec.ts
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
/**
|
||||||
|
* Copyright (c) Microsoft Corporation.
|
||||||
|
*
|
||||||
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
* you may not use this file except in compliance with the License.
|
||||||
|
* You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing, software
|
||||||
|
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
* See the License for the specific language governing permissions and
|
||||||
|
* limitations under the License.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { test, expect } from './fixtures.js';
|
||||||
|
|
||||||
|
for (const mcpHeadless of [false, true]) {
|
||||||
|
test.describe(`mcpHeadless: ${mcpHeadless}`, () => {
|
||||||
|
test.use({ mcpHeadless });
|
||||||
|
test.skip(process.platform === 'linux', 'Auto-detection wont let this test run on linux');
|
||||||
|
test.skip(({ mcpMode, mcpHeadless }) => mcpMode === 'docker' && !mcpHeadless, 'Headed mode is not supported in docker');
|
||||||
|
test('browser', async ({ client, server, mcpBrowser }) => {
|
||||||
|
test.skip(!['chrome', 'msedge', 'chromium'].includes(mcpBrowser ?? ''), 'Only chrome is supported for this test');
|
||||||
|
server.route('/', (req, res) => {
|
||||||
|
res.writeHead(200, { 'Content-Type': 'text/html' });
|
||||||
|
res.end(`
|
||||||
|
<body></body>
|
||||||
|
<script>
|
||||||
|
document.body.textContent = navigator.userAgent;
|
||||||
|
</script>
|
||||||
|
`);
|
||||||
|
});
|
||||||
|
|
||||||
|
const response = await client.callTool({
|
||||||
|
name: 'browser_navigate',
|
||||||
|
arguments: {
|
||||||
|
url: server.PREFIX,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response).toContainTextContent(`Mozilla/5.0`);
|
||||||
|
if (mcpHeadless)
|
||||||
|
expect(response).toContainTextContent(`HeadlessChrome`);
|
||||||
|
else
|
||||||
|
expect(response).not.toContainTextContent(`HeadlessChrome`);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
44
tests/iframes.spec.ts
Normal file
44
tests/iframes.spec.ts
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
/**
|
||||||
|
* Copyright (c) Microsoft Corporation.
|
||||||
|
*
|
||||||
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
* you may not use this file except in compliance with the License.
|
||||||
|
* You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing, software
|
||||||
|
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
* See the License for the specific language governing permissions and
|
||||||
|
* limitations under the License.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { test, expect } from './fixtures.js';
|
||||||
|
|
||||||
|
test('stitched aria frames', async ({ client }) => {
|
||||||
|
expect(await client.callTool({
|
||||||
|
name: 'browser_navigate',
|
||||||
|
arguments: {
|
||||||
|
url: `data:text/html,<h1>Hello</h1><iframe src="data:text/html,<button>World</button><main><iframe src='data:text/html,<p>Nested</p>'></iframe></main>"></iframe><iframe src="data:text/html,<h1>Should be invisible</h1>" style="display: none;"></iframe>`,
|
||||||
|
},
|
||||||
|
})).toContainTextContent(`
|
||||||
|
\`\`\`yaml
|
||||||
|
- generic [active] [ref=e1]:
|
||||||
|
- heading "Hello" [level=1] [ref=e2]
|
||||||
|
- iframe [ref=e3]:
|
||||||
|
- generic [active] [ref=f1e1]:
|
||||||
|
- button "World" [ref=f1e2]
|
||||||
|
- main [ref=f1e3]:
|
||||||
|
- iframe [ref=f1e4]:
|
||||||
|
- paragraph [ref=f2e2]: Nested
|
||||||
|
\`\`\``);
|
||||||
|
|
||||||
|
expect(await client.callTool({
|
||||||
|
name: 'browser_click',
|
||||||
|
arguments: {
|
||||||
|
element: 'World',
|
||||||
|
ref: 'f1e2',
|
||||||
|
},
|
||||||
|
})).toContainTextContent(`// Click World`);
|
||||||
|
});
|
||||||
@@ -14,22 +14,11 @@
|
|||||||
* limitations under the License.
|
* limitations under the License.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import type { Resource } from './resource';
|
import { test, expect } from './fixtures.js';
|
||||||
|
|
||||||
export const console: Resource = {
|
test('browser_install', async ({ client, mcpBrowser }) => {
|
||||||
schema: {
|
test.skip(mcpBrowser !== 'chromium', 'Test only chromium');
|
||||||
uri: 'browser://console',
|
expect(await client.callTool({
|
||||||
name: 'Page console',
|
name: 'browser_install',
|
||||||
mimeType: 'text/plain',
|
})).toContainTextContent(`No open pages available.`);
|
||||||
},
|
});
|
||||||
|
|
||||||
read: async (context, uri) => {
|
|
||||||
const messages = await context.console();
|
|
||||||
const log = messages.map(message => `[${message.type().toUpperCase()}] ${message.text()}`).join('\n');
|
|
||||||
return [{
|
|
||||||
uri,
|
|
||||||
mimeType: 'text/plain',
|
|
||||||
text: log
|
|
||||||
}];
|
|
||||||
},
|
|
||||||
};
|
|
||||||
157
tests/launch.spec.ts
Normal file
157
tests/launch.spec.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 fs from 'fs';
|
||||||
|
|
||||||
|
import { test, expect, formatOutput } from './fixtures.js';
|
||||||
|
|
||||||
|
test('test reopen browser', async ({ startClient, server, mcpMode }) => {
|
||||||
|
const { client, stderr } = await startClient();
|
||||||
|
await client.callTool({
|
||||||
|
name: 'browser_navigate',
|
||||||
|
arguments: { url: server.HELLO_WORLD },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(await client.callTool({
|
||||||
|
name: 'browser_close',
|
||||||
|
})).toContainTextContent('No open pages available');
|
||||||
|
|
||||||
|
expect(await client.callTool({
|
||||||
|
name: 'browser_navigate',
|
||||||
|
arguments: { url: server.HELLO_WORLD },
|
||||||
|
})).toContainTextContent(`- generic [active] [ref=e1]: Hello, world!`);
|
||||||
|
|
||||||
|
await client.close();
|
||||||
|
|
||||||
|
if (process.platform === 'win32')
|
||||||
|
return;
|
||||||
|
|
||||||
|
await expect.poll(() => formatOutput(stderr()), { timeout: 0 }).toEqual([
|
||||||
|
'create context',
|
||||||
|
'create browser context (persistent)',
|
||||||
|
'lock user data dir',
|
||||||
|
'close context',
|
||||||
|
'close browser context (persistent)',
|
||||||
|
'release user data dir',
|
||||||
|
'close browser context complete (persistent)',
|
||||||
|
'create browser context (persistent)',
|
||||||
|
'lock user data dir',
|
||||||
|
'close context',
|
||||||
|
'close browser context (persistent)',
|
||||||
|
'release user data dir',
|
||||||
|
'close browser context complete (persistent)',
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('executable path', async ({ startClient, server }) => {
|
||||||
|
const { client } = await startClient({ args: [`--executable-path=bogus`] });
|
||||||
|
const response = await client.callTool({
|
||||||
|
name: 'browser_navigate',
|
||||||
|
arguments: { url: server.HELLO_WORLD },
|
||||||
|
});
|
||||||
|
expect(response).toContainTextContent(`executable doesn't exist`);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('persistent context', async ({ startClient, server }) => {
|
||||||
|
server.setContent('/', `
|
||||||
|
<body>
|
||||||
|
</body>
|
||||||
|
<script>
|
||||||
|
document.body.textContent = localStorage.getItem('test') ? 'Storage: YES' : 'Storage: NO';
|
||||||
|
localStorage.setItem('test', 'test');
|
||||||
|
</script>
|
||||||
|
`, 'text/html');
|
||||||
|
|
||||||
|
const { client } = await startClient();
|
||||||
|
const response = await client.callTool({
|
||||||
|
name: 'browser_navigate',
|
||||||
|
arguments: { url: server.PREFIX },
|
||||||
|
});
|
||||||
|
expect(response).toContainTextContent(`Storage: NO`);
|
||||||
|
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 3000));
|
||||||
|
|
||||||
|
await client.callTool({
|
||||||
|
name: 'browser_close',
|
||||||
|
});
|
||||||
|
|
||||||
|
const { client: client2 } = await startClient();
|
||||||
|
const response2 = await client2.callTool({
|
||||||
|
name: 'browser_navigate',
|
||||||
|
arguments: { url: server.PREFIX },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response2).toContainTextContent(`Storage: YES`);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('isolated context', async ({ startClient, server }) => {
|
||||||
|
server.setContent('/', `
|
||||||
|
<body>
|
||||||
|
</body>
|
||||||
|
<script>
|
||||||
|
document.body.textContent = localStorage.getItem('test') ? 'Storage: YES' : 'Storage: NO';
|
||||||
|
localStorage.setItem('test', 'test');
|
||||||
|
</script>
|
||||||
|
`, 'text/html');
|
||||||
|
|
||||||
|
const { client: client1 } = await startClient({ args: [`--isolated`] });
|
||||||
|
const response = await client1.callTool({
|
||||||
|
name: 'browser_navigate',
|
||||||
|
arguments: { url: server.PREFIX },
|
||||||
|
});
|
||||||
|
expect(response).toContainTextContent(`Storage: NO`);
|
||||||
|
|
||||||
|
await client1.callTool({
|
||||||
|
name: 'browser_close',
|
||||||
|
});
|
||||||
|
|
||||||
|
const { client: client2 } = await startClient({ args: [`--isolated`] });
|
||||||
|
const response2 = await client2.callTool({
|
||||||
|
name: 'browser_navigate',
|
||||||
|
arguments: { url: server.PREFIX },
|
||||||
|
});
|
||||||
|
expect(response2).toContainTextContent(`Storage: NO`);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('isolated context with storage state', async ({ startClient, server }, testInfo) => {
|
||||||
|
const storageStatePath = testInfo.outputPath('storage-state.json');
|
||||||
|
await fs.promises.writeFile(storageStatePath, JSON.stringify({
|
||||||
|
origins: [
|
||||||
|
{
|
||||||
|
origin: server.PREFIX,
|
||||||
|
localStorage: [{ name: 'test', value: 'session-value' }],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}));
|
||||||
|
|
||||||
|
server.setContent('/', `
|
||||||
|
<body>
|
||||||
|
</body>
|
||||||
|
<script>
|
||||||
|
document.body.textContent = 'Storage: ' + localStorage.getItem('test');
|
||||||
|
</script>
|
||||||
|
`, 'text/html');
|
||||||
|
|
||||||
|
const { client } = await startClient({ args: [
|
||||||
|
`--isolated`,
|
||||||
|
`--storage-state=${storageStatePath}`,
|
||||||
|
] });
|
||||||
|
const response = await client.callTool({
|
||||||
|
name: 'browser_navigate',
|
||||||
|
arguments: { url: server.PREFIX },
|
||||||
|
});
|
||||||
|
expect(response).toContainTextContent(`Storage: session-value`);
|
||||||
|
});
|
||||||
28
tests/library.spec.ts
Normal file
28
tests/library.spec.ts
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
/**
|
||||||
|
* Copyright (c) Microsoft Corporation.
|
||||||
|
*
|
||||||
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
* you may not use this file except in compliance with the License.
|
||||||
|
* You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing, software
|
||||||
|
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
* See the License for the specific language governing permissions and
|
||||||
|
* limitations under the License.
|
||||||
|
*/
|
||||||
|
import { test, expect } from './fixtures.js';
|
||||||
|
import fs from 'node:fs/promises';
|
||||||
|
import child_process from 'node:child_process';
|
||||||
|
|
||||||
|
test('library can be used from CommonJS', { annotation: { type: 'issue', description: 'https://github.com/microsoft/playwright-mcp/issues/456' } }, async ({}, testInfo) => {
|
||||||
|
const file = testInfo.outputPath('main.cjs');
|
||||||
|
await fs.writeFile(file, `
|
||||||
|
import('@playwright/mcp')
|
||||||
|
.then(playwrightMCP => playwrightMCP.createConnection())
|
||||||
|
.then(() => console.log('OK'));
|
||||||
|
`);
|
||||||
|
expect(child_process.execSync(`node ${file}`, { encoding: 'utf-8' })).toContain('OK');
|
||||||
|
});
|
||||||
45
tests/network.spec.ts
Normal file
45
tests/network.spec.ts
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
/**
|
||||||
|
* Copyright (c) Microsoft Corporation.
|
||||||
|
*
|
||||||
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
* you may not use this file except in compliance with the License.
|
||||||
|
* You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing, software
|
||||||
|
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
* See the License for the specific language governing permissions and
|
||||||
|
* limitations under the License.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { test, expect } from './fixtures.js';
|
||||||
|
|
||||||
|
test('browser_network_requests', async ({ client, server }) => {
|
||||||
|
server.setContent('/', `
|
||||||
|
<button onclick="fetch('/json')">Click me</button>
|
||||||
|
`, 'text/html');
|
||||||
|
|
||||||
|
server.setContent('/json', JSON.stringify({ name: 'John Doe' }), 'application/json');
|
||||||
|
|
||||||
|
await client.callTool({
|
||||||
|
name: 'browser_navigate',
|
||||||
|
arguments: {
|
||||||
|
url: server.PREFIX,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await client.callTool({
|
||||||
|
name: 'browser_click',
|
||||||
|
arguments: {
|
||||||
|
element: 'Click me button',
|
||||||
|
ref: 'e2',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect.poll(() => client.callTool({
|
||||||
|
name: 'browser_network_requests',
|
||||||
|
})).toHaveTextContent(`[GET] ${`${server.PREFIX}`} => [200] OK
|
||||||
|
[GET] ${`${server.PREFIX}json`} => [200] OK`);
|
||||||
|
});
|
||||||
83
tests/pdf.spec.ts
Normal file
83
tests/pdf.spec.ts
Normal file
@@ -0,0 +1,83 @@
|
|||||||
|
/**
|
||||||
|
* Copyright (c) Microsoft Corporation.
|
||||||
|
*
|
||||||
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
* you may not use this file except in compliance with the License.
|
||||||
|
* You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing, software
|
||||||
|
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
* See the License for the specific language governing permissions and
|
||||||
|
* limitations under the License.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import fs from 'fs';
|
||||||
|
|
||||||
|
import { test, expect } from './fixtures.js';
|
||||||
|
|
||||||
|
test('save as pdf unavailable', async ({ startClient, server }) => {
|
||||||
|
const { client } = await startClient({ args: ['--caps="no-pdf"'] });
|
||||||
|
await client.callTool({
|
||||||
|
name: 'browser_navigate',
|
||||||
|
arguments: { url: server.HELLO_WORLD },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(await client.callTool({
|
||||||
|
name: 'browser_pdf_save',
|
||||||
|
})).toHaveTextContent(/Tool \"browser_pdf_save\" not found/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('save as pdf', async ({ startClient, mcpBrowser, server }, testInfo) => {
|
||||||
|
const { client } = await startClient({
|
||||||
|
config: { outputDir: testInfo.outputPath('output') },
|
||||||
|
});
|
||||||
|
|
||||||
|
test.skip(!!mcpBrowser && !['chromium', 'chrome', 'msedge'].includes(mcpBrowser), 'Save as PDF is only supported in Chromium.');
|
||||||
|
|
||||||
|
expect(await client.callTool({
|
||||||
|
name: 'browser_navigate',
|
||||||
|
arguments: { url: server.HELLO_WORLD },
|
||||||
|
})).toContainTextContent(`- generic [active] [ref=e1]: Hello, world!`);
|
||||||
|
|
||||||
|
const response = await client.callTool({
|
||||||
|
name: 'browser_pdf_save',
|
||||||
|
});
|
||||||
|
expect(response).toHaveTextContent(/Save page as.*page-[^:]+.pdf/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('save as pdf (filename: output.pdf)', async ({ startClient, mcpBrowser, server }, testInfo) => {
|
||||||
|
const outputDir = testInfo.outputPath('output');
|
||||||
|
test.skip(!!mcpBrowser && !['chromium', 'chrome', 'msedge'].includes(mcpBrowser), 'Save as PDF is only supported in Chromium.');
|
||||||
|
const { client } = await startClient({
|
||||||
|
config: { outputDir },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(await client.callTool({
|
||||||
|
name: 'browser_navigate',
|
||||||
|
arguments: { url: server.HELLO_WORLD },
|
||||||
|
})).toContainTextContent(`- generic [active] [ref=e1]: Hello, world!`);
|
||||||
|
|
||||||
|
expect(await client.callTool({
|
||||||
|
name: 'browser_pdf_save',
|
||||||
|
arguments: {
|
||||||
|
filename: 'output.pdf',
|
||||||
|
},
|
||||||
|
})).toEqual({
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: 'text',
|
||||||
|
text: expect.stringContaining(`output.pdf`),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
const files = [...fs.readdirSync(outputDir)];
|
||||||
|
|
||||||
|
expect(fs.existsSync(outputDir)).toBeTruthy();
|
||||||
|
const pdfFiles = files.filter(f => f.endsWith('.pdf'));
|
||||||
|
expect(pdfFiles).toHaveLength(1);
|
||||||
|
expect(pdfFiles[0]).toMatch(/^output.pdf$/);
|
||||||
|
});
|
||||||
82
tests/request-blocking.spec.ts
Normal file
82
tests/request-blocking.spec.ts
Normal file
@@ -0,0 +1,82 @@
|
|||||||
|
/**
|
||||||
|
* Copyright (c) Microsoft Corporation.
|
||||||
|
*
|
||||||
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
* you may not use this file except in compliance with the License.
|
||||||
|
* You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing, software
|
||||||
|
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
* See the License for the specific language governing permissions and
|
||||||
|
* limitations under the License.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
|
||||||
|
import { test, expect } from './fixtures.ts';
|
||||||
|
|
||||||
|
const BLOCK_MESSAGE = /Blocked by Web Inspector|NS_ERROR_FAILURE|net::ERR_BLOCKED_BY_CLIENT/g;
|
||||||
|
|
||||||
|
const fetchPage = async (client: Client, url: string) => {
|
||||||
|
const result = await client.callTool({
|
||||||
|
name: 'browser_navigate',
|
||||||
|
arguments: {
|
||||||
|
url,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return JSON.stringify(result, null, 2);
|
||||||
|
};
|
||||||
|
|
||||||
|
test('default to allow all', async ({ server, client }) => {
|
||||||
|
server.setContent('/ppp', 'content:PPP', 'text/html');
|
||||||
|
const result = await fetchPage(client, server.PREFIX + 'ppp');
|
||||||
|
expect(result).toContain('content:PPP');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('blocked works', async ({ startClient }) => {
|
||||||
|
const { client } = await startClient({
|
||||||
|
args: ['--blocked-origins', 'microsoft.com;example.com;playwright.dev']
|
||||||
|
});
|
||||||
|
const result = await fetchPage(client, 'https://example.com/');
|
||||||
|
expect(result).toMatch(BLOCK_MESSAGE);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('allowed works', async ({ server, startClient }) => {
|
||||||
|
server.setContent('/ppp', 'content:PPP', 'text/html');
|
||||||
|
const { client } = await startClient({
|
||||||
|
args: ['--allowed-origins', `microsoft.com;${new URL(server.PREFIX).host};playwright.dev`]
|
||||||
|
});
|
||||||
|
const result = await fetchPage(client, server.PREFIX + 'ppp');
|
||||||
|
expect(result).toContain('content:PPP');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('blocked takes precedence', async ({ startClient }) => {
|
||||||
|
const { client } = await startClient({
|
||||||
|
args: [
|
||||||
|
'--blocked-origins', 'example.com',
|
||||||
|
'--allowed-origins', 'example.com',
|
||||||
|
],
|
||||||
|
});
|
||||||
|
const result = await fetchPage(client, 'https://example.com/');
|
||||||
|
expect(result).toMatch(BLOCK_MESSAGE);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('allowed without blocked blocks all non-explicitly specified origins', async ({ startClient }) => {
|
||||||
|
const { client } = await startClient({
|
||||||
|
args: ['--allowed-origins', 'playwright.dev'],
|
||||||
|
});
|
||||||
|
const result = await fetchPage(client, 'https://example.com/');
|
||||||
|
expect(result).toMatch(BLOCK_MESSAGE);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('blocked without allowed allows non-explicitly specified origins', async ({ server, startClient }) => {
|
||||||
|
server.setContent('/ppp', 'content:PPP', 'text/html');
|
||||||
|
const { client } = await startClient({
|
||||||
|
args: ['--blocked-origins', 'example.com'],
|
||||||
|
});
|
||||||
|
const result = await fetchPage(client, server.PREFIX + 'ppp');
|
||||||
|
expect(result).toContain('content:PPP');
|
||||||
|
});
|
||||||
232
tests/screenshot.spec.ts
Normal file
232
tests/screenshot.spec.ts
Normal file
@@ -0,0 +1,232 @@
|
|||||||
|
/**
|
||||||
|
* Copyright (c) Microsoft Corporation.
|
||||||
|
*
|
||||||
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
* you may not use this file except in compliance with the License.
|
||||||
|
* You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing, software
|
||||||
|
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
* See the License for the specific language governing permissions and
|
||||||
|
* limitations under the License.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import fs from 'fs';
|
||||||
|
|
||||||
|
import { test, expect } from './fixtures.js';
|
||||||
|
|
||||||
|
test('browser_take_screenshot (viewport)', async ({ startClient, server }, testInfo) => {
|
||||||
|
const { client } = await startClient({
|
||||||
|
config: { outputDir: testInfo.outputPath('output') },
|
||||||
|
});
|
||||||
|
expect(await client.callTool({
|
||||||
|
name: 'browser_navigate',
|
||||||
|
arguments: { url: server.HELLO_WORLD },
|
||||||
|
})).toContainTextContent(`Navigate to http://localhost`);
|
||||||
|
|
||||||
|
expect(await client.callTool({
|
||||||
|
name: 'browser_take_screenshot',
|
||||||
|
})).toEqual({
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
data: expect.any(String),
|
||||||
|
mimeType: 'image/jpeg',
|
||||||
|
type: 'image',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
text: expect.stringContaining(`Screenshot viewport and save it as`),
|
||||||
|
type: 'text',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('browser_take_screenshot (element)', async ({ startClient, server }, testInfo) => {
|
||||||
|
const { client } = await startClient({
|
||||||
|
config: { outputDir: testInfo.outputPath('output') },
|
||||||
|
});
|
||||||
|
expect(await client.callTool({
|
||||||
|
name: 'browser_navigate',
|
||||||
|
arguments: { url: server.HELLO_WORLD },
|
||||||
|
})).toContainTextContent(`[ref=e1]`);
|
||||||
|
|
||||||
|
expect(await client.callTool({
|
||||||
|
name: 'browser_take_screenshot',
|
||||||
|
arguments: {
|
||||||
|
element: 'hello button',
|
||||||
|
ref: 'e1',
|
||||||
|
},
|
||||||
|
})).toEqual({
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
data: expect.any(String),
|
||||||
|
mimeType: 'image/jpeg',
|
||||||
|
type: 'image',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
text: expect.stringContaining(`page.getByText('Hello, world!').screenshot`),
|
||||||
|
type: 'text',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('--output-dir should work', async ({ startClient, server }, testInfo) => {
|
||||||
|
const outputDir = testInfo.outputPath('output');
|
||||||
|
const { client } = await startClient({
|
||||||
|
config: { outputDir },
|
||||||
|
});
|
||||||
|
expect(await client.callTool({
|
||||||
|
name: 'browser_navigate',
|
||||||
|
arguments: { url: server.HELLO_WORLD },
|
||||||
|
})).toContainTextContent(`Navigate to http://localhost`);
|
||||||
|
|
||||||
|
await client.callTool({
|
||||||
|
name: 'browser_take_screenshot',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(fs.existsSync(outputDir)).toBeTruthy();
|
||||||
|
const files = [...fs.readdirSync(outputDir)].filter(f => f.endsWith('.jpeg'));
|
||||||
|
expect(files).toHaveLength(1);
|
||||||
|
expect(files[0]).toMatch(/^page-\d{4}-\d{2}-\d{2}T\d{2}-\d{2}-\d{2}-\d{3}Z\.jpeg$/);
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const raw of [undefined, true]) {
|
||||||
|
test(`browser_take_screenshot (raw: ${raw})`, async ({ startClient, server }, testInfo) => {
|
||||||
|
const outputDir = testInfo.outputPath('output');
|
||||||
|
const ext = raw ? 'png' : 'jpeg';
|
||||||
|
const { client } = await startClient({
|
||||||
|
config: { outputDir },
|
||||||
|
});
|
||||||
|
expect(await client.callTool({
|
||||||
|
name: 'browser_navigate',
|
||||||
|
arguments: { url: server.PREFIX },
|
||||||
|
})).toContainTextContent(`Navigate to http://localhost`);
|
||||||
|
|
||||||
|
expect(await client.callTool({
|
||||||
|
name: 'browser_take_screenshot',
|
||||||
|
arguments: { raw },
|
||||||
|
})).toEqual({
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
data: expect.any(String),
|
||||||
|
mimeType: `image/${ext}`,
|
||||||
|
type: 'image',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
text: expect.stringMatching(
|
||||||
|
new RegExp(`page-\\d{4}-\\d{2}-\\d{2}T\\d{2}-\\d{2}-\\d{2}\\-\\d{3}Z\\.${ext}`)
|
||||||
|
),
|
||||||
|
type: 'text',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
const files = [...fs.readdirSync(outputDir)].filter(f => f.endsWith(`.${ext}`));
|
||||||
|
|
||||||
|
expect(fs.existsSync(outputDir)).toBeTruthy();
|
||||||
|
expect(files).toHaveLength(1);
|
||||||
|
expect(files[0]).toMatch(
|
||||||
|
new RegExp(`^page-\\d{4}-\\d{2}-\\d{2}T\\d{2}-\\d{2}-\\d{2}-\\d{3}Z\\.${ext}$`)
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
test('browser_take_screenshot (filename: "output.jpeg")', async ({ startClient, server }, testInfo) => {
|
||||||
|
const outputDir = testInfo.outputPath('output');
|
||||||
|
const { client } = await startClient({
|
||||||
|
config: { outputDir },
|
||||||
|
});
|
||||||
|
expect(await client.callTool({
|
||||||
|
name: 'browser_navigate',
|
||||||
|
arguments: { url: server.HELLO_WORLD },
|
||||||
|
})).toContainTextContent(`Navigate to http://localhost`);
|
||||||
|
|
||||||
|
expect(await client.callTool({
|
||||||
|
name: 'browser_take_screenshot',
|
||||||
|
arguments: {
|
||||||
|
filename: 'output.jpeg',
|
||||||
|
},
|
||||||
|
})).toEqual({
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
data: expect.any(String),
|
||||||
|
mimeType: 'image/jpeg',
|
||||||
|
type: 'image',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
text: expect.stringContaining(`output.jpeg`),
|
||||||
|
type: 'text',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
const files = [...fs.readdirSync(outputDir)].filter(f => f.endsWith('.jpeg'));
|
||||||
|
|
||||||
|
expect(fs.existsSync(outputDir)).toBeTruthy();
|
||||||
|
expect(files).toHaveLength(1);
|
||||||
|
expect(files[0]).toMatch(/^output\.jpeg$/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('browser_take_screenshot (imageResponses=omit)', async ({ startClient, server }, testInfo) => {
|
||||||
|
const outputDir = testInfo.outputPath('output');
|
||||||
|
const { client } = await startClient({
|
||||||
|
config: {
|
||||||
|
outputDir,
|
||||||
|
imageResponses: 'omit',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(await client.callTool({
|
||||||
|
name: 'browser_navigate',
|
||||||
|
arguments: { url: server.HELLO_WORLD },
|
||||||
|
})).toContainTextContent(`Navigate to http://localhost`);
|
||||||
|
|
||||||
|
await client.callTool({
|
||||||
|
name: 'browser_take_screenshot',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(await client.callTool({
|
||||||
|
name: 'browser_take_screenshot',
|
||||||
|
})).toEqual({
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
text: expect.stringContaining(`Screenshot viewport and save it as`),
|
||||||
|
type: 'text',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('browser_take_screenshot (cursor)', async ({ startClient, server }, testInfo) => {
|
||||||
|
const outputDir = testInfo.outputPath('output');
|
||||||
|
|
||||||
|
const { client } = await startClient({
|
||||||
|
clientName: 'cursor:vscode',
|
||||||
|
config: { outputDir },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(await client.callTool({
|
||||||
|
name: 'browser_navigate',
|
||||||
|
arguments: { url: server.HELLO_WORLD },
|
||||||
|
})).toContainTextContent(`Navigate to http://localhost`);
|
||||||
|
|
||||||
|
await client.callTool({
|
||||||
|
name: 'browser_take_screenshot',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(await client.callTool({
|
||||||
|
name: 'browser_take_screenshot',
|
||||||
|
})).toEqual({
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
text: expect.stringContaining(`Screenshot viewport and save it as`),
|
||||||
|
type: 'text',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
});
|
||||||
246
tests/sse.spec.ts
Normal file
246
tests/sse.spec.ts
Normal file
@@ -0,0 +1,246 @@
|
|||||||
|
/**
|
||||||
|
* Copyright (c) Microsoft Corporation.
|
||||||
|
*
|
||||||
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
* you may not use this file except in compliance with the License.
|
||||||
|
* You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing, software
|
||||||
|
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
* See the License for the specific language governing permissions and
|
||||||
|
* limitations under the License.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import fs from 'node:fs';
|
||||||
|
import url from 'node:url';
|
||||||
|
|
||||||
|
import { ChildProcess, spawn } from 'node:child_process';
|
||||||
|
import path from 'node:path';
|
||||||
|
import { SSEClientTransport } from '@modelcontextprotocol/sdk/client/sse.js';
|
||||||
|
import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js';
|
||||||
|
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
|
||||||
|
|
||||||
|
import { test as baseTest, expect } from './fixtures.js';
|
||||||
|
import type { Config } from '../config.d.ts';
|
||||||
|
|
||||||
|
// NOTE: Can be removed when we drop Node.js 18 support and changed to import.meta.filename.
|
||||||
|
const __filename = url.fileURLToPath(import.meta.url);
|
||||||
|
|
||||||
|
const test = baseTest.extend<{ serverEndpoint: (options?: { args?: string[], noPort?: boolean }) => Promise<{ url: URL, stderr: () => string }> }>({
|
||||||
|
serverEndpoint: async ({ mcpHeadless }, use, testInfo) => {
|
||||||
|
let cp: ChildProcess | undefined;
|
||||||
|
const userDataDir = testInfo.outputPath('user-data-dir');
|
||||||
|
await use(async (options?: { args?: string[], noPort?: boolean }) => {
|
||||||
|
if (cp)
|
||||||
|
throw new Error('Process already running');
|
||||||
|
|
||||||
|
cp = spawn('node', [
|
||||||
|
path.join(path.dirname(__filename), '../cli.js'),
|
||||||
|
...(options?.noPort ? [] : ['--port=0']),
|
||||||
|
'--user-data-dir=' + userDataDir,
|
||||||
|
...(mcpHeadless ? ['--headless'] : []),
|
||||||
|
...(options?.args || []),
|
||||||
|
], {
|
||||||
|
stdio: 'pipe',
|
||||||
|
env: {
|
||||||
|
...process.env,
|
||||||
|
DEBUG: 'pw:mcp:test',
|
||||||
|
DEBUG_COLORS: '0',
|
||||||
|
DEBUG_HIDE_DATE: '1',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
let stderr = '';
|
||||||
|
const url = await new Promise<string>(resolve => cp!.stderr?.on('data', data => {
|
||||||
|
stderr += data.toString();
|
||||||
|
const match = stderr.match(/Listening on (http:\/\/.*)/);
|
||||||
|
if (match)
|
||||||
|
resolve(match[1]);
|
||||||
|
}));
|
||||||
|
|
||||||
|
return { url: new URL(url), stderr: () => stderr };
|
||||||
|
});
|
||||||
|
cp?.kill('SIGTERM');
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
test('sse transport', async ({ serverEndpoint }) => {
|
||||||
|
const { url } = await serverEndpoint();
|
||||||
|
const transport = new SSEClientTransport(url);
|
||||||
|
const client = new Client({ name: 'test', version: '1.0.0' });
|
||||||
|
await client.connect(transport);
|
||||||
|
await client.ping();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('sse transport (config)', async ({ serverEndpoint }) => {
|
||||||
|
const config: Config = {
|
||||||
|
server: {
|
||||||
|
port: 0,
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const configFile = test.info().outputPath('config.json');
|
||||||
|
await fs.promises.writeFile(configFile, JSON.stringify(config, null, 2));
|
||||||
|
|
||||||
|
const { url } = await serverEndpoint({ noPort: true, args: ['--config=' + configFile] });
|
||||||
|
const transport = new SSEClientTransport(url);
|
||||||
|
const client = new Client({ name: 'test', version: '1.0.0' });
|
||||||
|
await client.connect(transport);
|
||||||
|
await client.ping();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('sse transport browser lifecycle (isolated)', async ({ serverEndpoint, server }) => {
|
||||||
|
const { url, stderr } = await serverEndpoint({ args: ['--isolated'] });
|
||||||
|
|
||||||
|
const transport1 = new SSEClientTransport(url);
|
||||||
|
const client1 = new Client({ name: 'test', version: '1.0.0' });
|
||||||
|
await client1.connect(transport1);
|
||||||
|
await client1.callTool({
|
||||||
|
name: 'browser_navigate',
|
||||||
|
arguments: { url: server.HELLO_WORLD },
|
||||||
|
});
|
||||||
|
await client1.close();
|
||||||
|
|
||||||
|
const transport2 = new SSEClientTransport(url);
|
||||||
|
const client2 = new Client({ name: 'test', version: '1.0.0' });
|
||||||
|
await client2.connect(transport2);
|
||||||
|
await client2.callTool({
|
||||||
|
name: 'browser_navigate',
|
||||||
|
arguments: { url: server.HELLO_WORLD },
|
||||||
|
});
|
||||||
|
await client2.close();
|
||||||
|
|
||||||
|
await expect(async () => {
|
||||||
|
const lines = stderr().split('\n');
|
||||||
|
expect(lines.filter(line => line.match(/create SSE session/)).length).toBe(2);
|
||||||
|
expect(lines.filter(line => line.match(/delete SSE session/)).length).toBe(2);
|
||||||
|
|
||||||
|
expect(lines.filter(line => line.match(/create context/)).length).toBe(2);
|
||||||
|
expect(lines.filter(line => line.match(/close context/)).length).toBe(2);
|
||||||
|
|
||||||
|
expect(lines.filter(line => line.match(/create browser context \(isolated\)/)).length).toBe(2);
|
||||||
|
expect(lines.filter(line => line.match(/close browser context \(isolated\)/)).length).toBe(2);
|
||||||
|
|
||||||
|
expect(lines.filter(line => line.match(/obtain browser \(isolated\)/)).length).toBe(2);
|
||||||
|
expect(lines.filter(line => line.match(/close browser \(isolated\)/)).length).toBe(2);
|
||||||
|
}).toPass();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('sse transport browser lifecycle (isolated, multiclient)', async ({ serverEndpoint, server }) => {
|
||||||
|
const { url, stderr } = await serverEndpoint({ args: ['--isolated'] });
|
||||||
|
|
||||||
|
const transport1 = new SSEClientTransport(url);
|
||||||
|
const client1 = new Client({ name: 'test', version: '1.0.0' });
|
||||||
|
await client1.connect(transport1);
|
||||||
|
await client1.callTool({
|
||||||
|
name: 'browser_navigate',
|
||||||
|
arguments: { url: server.HELLO_WORLD },
|
||||||
|
});
|
||||||
|
|
||||||
|
const transport2 = new SSEClientTransport(url);
|
||||||
|
const client2 = new Client({ name: 'test', version: '1.0.0' });
|
||||||
|
await client2.connect(transport2);
|
||||||
|
await client2.callTool({
|
||||||
|
name: 'browser_navigate',
|
||||||
|
arguments: { url: server.HELLO_WORLD },
|
||||||
|
});
|
||||||
|
await client1.close();
|
||||||
|
|
||||||
|
const transport3 = new SSEClientTransport(url);
|
||||||
|
const client3 = new Client({ name: 'test', version: '1.0.0' });
|
||||||
|
await client3.connect(transport3);
|
||||||
|
await client3.callTool({
|
||||||
|
name: 'browser_navigate',
|
||||||
|
arguments: { url: server.HELLO_WORLD },
|
||||||
|
});
|
||||||
|
|
||||||
|
await client2.close();
|
||||||
|
await client3.close();
|
||||||
|
|
||||||
|
await expect(async () => {
|
||||||
|
const lines = stderr().split('\n');
|
||||||
|
expect(lines.filter(line => line.match(/create SSE session/)).length).toBe(3);
|
||||||
|
expect(lines.filter(line => line.match(/delete SSE session/)).length).toBe(3);
|
||||||
|
|
||||||
|
expect(lines.filter(line => line.match(/create context/)).length).toBe(3);
|
||||||
|
expect(lines.filter(line => line.match(/close context/)).length).toBe(3);
|
||||||
|
|
||||||
|
expect(lines.filter(line => line.match(/create browser context \(isolated\)/)).length).toBe(3);
|
||||||
|
expect(lines.filter(line => line.match(/close browser context \(isolated\)/)).length).toBe(3);
|
||||||
|
|
||||||
|
expect(lines.filter(line => line.match(/obtain browser \(isolated\)/)).length).toBe(1);
|
||||||
|
expect(lines.filter(line => line.match(/close browser \(isolated\)/)).length).toBe(1);
|
||||||
|
}).toPass();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('sse transport browser lifecycle (persistent)', async ({ serverEndpoint, server }) => {
|
||||||
|
const { url, stderr } = await serverEndpoint();
|
||||||
|
|
||||||
|
const transport1 = new SSEClientTransport(url);
|
||||||
|
const client1 = new Client({ name: 'test', version: '1.0.0' });
|
||||||
|
await client1.connect(transport1);
|
||||||
|
await client1.callTool({
|
||||||
|
name: 'browser_navigate',
|
||||||
|
arguments: { url: server.HELLO_WORLD },
|
||||||
|
});
|
||||||
|
await client1.close();
|
||||||
|
|
||||||
|
const transport2 = new SSEClientTransport(url);
|
||||||
|
const client2 = new Client({ name: 'test', version: '1.0.0' });
|
||||||
|
await client2.connect(transport2);
|
||||||
|
await client2.callTool({
|
||||||
|
name: 'browser_navigate',
|
||||||
|
arguments: { url: server.HELLO_WORLD },
|
||||||
|
});
|
||||||
|
await client2.close();
|
||||||
|
|
||||||
|
await expect(async () => {
|
||||||
|
const lines = stderr().split('\n');
|
||||||
|
expect(lines.filter(line => line.match(/create SSE session/)).length).toBe(2);
|
||||||
|
expect(lines.filter(line => line.match(/delete SSE session/)).length).toBe(2);
|
||||||
|
|
||||||
|
expect(lines.filter(line => line.match(/create context/)).length).toBe(2);
|
||||||
|
expect(lines.filter(line => line.match(/close context/)).length).toBe(2);
|
||||||
|
|
||||||
|
expect(lines.filter(line => line.match(/create browser context \(persistent\)/)).length).toBe(2);
|
||||||
|
expect(lines.filter(line => line.match(/close browser context \(persistent\)/)).length).toBe(2);
|
||||||
|
|
||||||
|
expect(lines.filter(line => line.match(/lock user data dir/)).length).toBe(2);
|
||||||
|
expect(lines.filter(line => line.match(/release user data dir/)).length).toBe(2);
|
||||||
|
}).toPass();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('sse transport browser lifecycle (persistent, multiclient)', async ({ serverEndpoint, server }) => {
|
||||||
|
const { url } = await serverEndpoint();
|
||||||
|
|
||||||
|
const transport1 = new SSEClientTransport(url);
|
||||||
|
const client1 = new Client({ name: 'test', version: '1.0.0' });
|
||||||
|
await client1.connect(transport1);
|
||||||
|
await client1.callTool({
|
||||||
|
name: 'browser_navigate',
|
||||||
|
arguments: { url: server.HELLO_WORLD },
|
||||||
|
});
|
||||||
|
|
||||||
|
const transport2 = new SSEClientTransport(url);
|
||||||
|
const client2 = new Client({ name: 'test', version: '1.0.0' });
|
||||||
|
await client2.connect(transport2);
|
||||||
|
const response = await client2.callTool({
|
||||||
|
name: 'browser_navigate',
|
||||||
|
arguments: { url: server.HELLO_WORLD },
|
||||||
|
});
|
||||||
|
expect(response.isError).toBe(true);
|
||||||
|
expect(response.content?.[0].text).toContain('use --isolated to run multiple instances of the same browser');
|
||||||
|
|
||||||
|
await client1.close();
|
||||||
|
await client2.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('streamable http transport', async ({ serverEndpoint }) => {
|
||||||
|
const { url } = await serverEndpoint();
|
||||||
|
const transport = new StreamableHTTPClientTransport(new URL('/mcp', url));
|
||||||
|
const client = new Client({ name: 'test', version: '1.0.0' });
|
||||||
|
await client.connect(transport);
|
||||||
|
await client.ping();
|
||||||
|
expect(transport.sessionId, 'has session support').toBeDefined();
|
||||||
|
});
|
||||||
152
tests/tabs.spec.ts
Normal file
152
tests/tabs.spec.ts
Normal file
@@ -0,0 +1,152 @@
|
|||||||
|
/**
|
||||||
|
* Copyright (c) Microsoft Corporation.
|
||||||
|
*
|
||||||
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
* you may not use this file except in compliance with the License.
|
||||||
|
* You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing, software
|
||||||
|
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
* See the License for the specific language governing permissions and
|
||||||
|
* limitations under the License.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { test, expect } from './fixtures.js';
|
||||||
|
|
||||||
|
import type { Client } from '@modelcontextprotocol/sdk/client/index.js';
|
||||||
|
|
||||||
|
async function createTab(client: Client, title: string, body: string) {
|
||||||
|
return await client.callTool({
|
||||||
|
name: 'browser_tab_new',
|
||||||
|
arguments: {
|
||||||
|
url: `data:text/html,<title>${title}</title><body>${body}</body>`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
test('list initial tabs', async ({ client }) => {
|
||||||
|
expect(await client.callTool({
|
||||||
|
name: 'browser_tab_list',
|
||||||
|
})).toHaveTextContent(`### Open tabs
|
||||||
|
- 1: (current) [] (about:blank)`);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('list first tab', async ({ client }) => {
|
||||||
|
await createTab(client, 'Tab one', 'Body one');
|
||||||
|
expect(await client.callTool({
|
||||||
|
name: 'browser_tab_list',
|
||||||
|
})).toHaveTextContent(`### Open tabs
|
||||||
|
- 1: [] (about:blank)
|
||||||
|
- 2: (current) [Tab one] (data:text/html,<title>Tab one</title><body>Body one</body>)`);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('create new tab', async ({ client }) => {
|
||||||
|
expect(await createTab(client, 'Tab one', 'Body one')).toHaveTextContent(`
|
||||||
|
- Ran Playwright code:
|
||||||
|
\`\`\`js
|
||||||
|
// <internal code to open a new tab>
|
||||||
|
\`\`\`
|
||||||
|
|
||||||
|
### Open tabs
|
||||||
|
- 1: [] (about:blank)
|
||||||
|
- 2: (current) [Tab one] (data:text/html,<title>Tab one</title><body>Body one</body>)
|
||||||
|
|
||||||
|
### Current tab
|
||||||
|
- Page URL: data:text/html,<title>Tab one</title><body>Body one</body>
|
||||||
|
- Page Title: Tab one
|
||||||
|
- Page Snapshot
|
||||||
|
\`\`\`yaml
|
||||||
|
- generic [active] [ref=e1]: Body one
|
||||||
|
\`\`\``);
|
||||||
|
|
||||||
|
expect(await createTab(client, 'Tab two', 'Body two')).toHaveTextContent(`
|
||||||
|
- Ran Playwright code:
|
||||||
|
\`\`\`js
|
||||||
|
// <internal code to open a new tab>
|
||||||
|
\`\`\`
|
||||||
|
|
||||||
|
### Open tabs
|
||||||
|
- 1: [] (about:blank)
|
||||||
|
- 2: [Tab one] (data:text/html,<title>Tab one</title><body>Body one</body>)
|
||||||
|
- 3: (current) [Tab two] (data:text/html,<title>Tab two</title><body>Body two</body>)
|
||||||
|
|
||||||
|
### Current tab
|
||||||
|
- Page URL: data:text/html,<title>Tab two</title><body>Body two</body>
|
||||||
|
- Page Title: Tab two
|
||||||
|
- Page Snapshot
|
||||||
|
\`\`\`yaml
|
||||||
|
- generic [active] [ref=e1]: Body two
|
||||||
|
\`\`\``);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('select tab', async ({ client }) => {
|
||||||
|
await createTab(client, 'Tab one', 'Body one');
|
||||||
|
await createTab(client, 'Tab two', 'Body two');
|
||||||
|
expect(await client.callTool({
|
||||||
|
name: 'browser_tab_select',
|
||||||
|
arguments: {
|
||||||
|
index: 2,
|
||||||
|
},
|
||||||
|
})).toHaveTextContent(`
|
||||||
|
- Ran Playwright code:
|
||||||
|
\`\`\`js
|
||||||
|
// <internal code to select tab 2>
|
||||||
|
\`\`\`
|
||||||
|
|
||||||
|
### Open tabs
|
||||||
|
- 1: [] (about:blank)
|
||||||
|
- 2: (current) [Tab one] (data:text/html,<title>Tab one</title><body>Body one</body>)
|
||||||
|
- 3: [Tab two] (data:text/html,<title>Tab two</title><body>Body two</body>)
|
||||||
|
|
||||||
|
### Current tab
|
||||||
|
- Page URL: data:text/html,<title>Tab one</title><body>Body one</body>
|
||||||
|
- Page Title: Tab one
|
||||||
|
- Page Snapshot
|
||||||
|
\`\`\`yaml
|
||||||
|
- generic [active] [ref=e1]: Body one
|
||||||
|
\`\`\``);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('close tab', async ({ client }) => {
|
||||||
|
await createTab(client, 'Tab one', 'Body one');
|
||||||
|
await createTab(client, 'Tab two', 'Body two');
|
||||||
|
expect(await client.callTool({
|
||||||
|
name: 'browser_tab_close',
|
||||||
|
arguments: {
|
||||||
|
index: 3,
|
||||||
|
},
|
||||||
|
})).toHaveTextContent(`
|
||||||
|
- Ran Playwright code:
|
||||||
|
\`\`\`js
|
||||||
|
// <internal code to close tab 3>
|
||||||
|
\`\`\`
|
||||||
|
|
||||||
|
### Open tabs
|
||||||
|
- 1: [] (about:blank)
|
||||||
|
- 2: (current) [Tab one] (data:text/html,<title>Tab one</title><body>Body one</body>)
|
||||||
|
|
||||||
|
### Current tab
|
||||||
|
- Page URL: data:text/html,<title>Tab one</title><body>Body one</body>
|
||||||
|
- Page Title: Tab one
|
||||||
|
- Page Snapshot
|
||||||
|
\`\`\`yaml
|
||||||
|
- generic [active] [ref=e1]: Body one
|
||||||
|
\`\`\``);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('reuse first tab when navigating', async ({ startClient, cdpServer, server }) => {
|
||||||
|
const browserContext = await cdpServer.start();
|
||||||
|
const pages = browserContext.pages();
|
||||||
|
|
||||||
|
const { client } = await startClient({ args: [`--cdp-endpoint=${cdpServer.endpoint}`] });
|
||||||
|
await client.callTool({
|
||||||
|
name: 'browser_navigate',
|
||||||
|
arguments: { url: server.HELLO_WORLD },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(pages.length).toBe(1);
|
||||||
|
expect(await pages[0].title()).toBe('Title');
|
||||||
|
});
|
||||||
29
tests/testserver/cert.pem
Normal file
29
tests/testserver/cert.pem
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
-----BEGIN CERTIFICATE-----
|
||||||
|
MIIFCjCCAvKgAwIBAgIULU/gkDm8IqC7PG8u3RID0AYyP6gwDQYJKoZIhvcNAQEL
|
||||||
|
BQAwGjEYMBYGA1UEAwwPcGxheXdyaWdodC10ZXN0MB4XDTIzMDgxMDIyNTc1MFoX
|
||||||
|
DTMzMDgwNzIyNTc1MFowGjEYMBYGA1UEAwwPcGxheXdyaWdodC10ZXN0MIICIjAN
|
||||||
|
BgkqhkiG9w0BAQEFAAOCAg8AMIICCgKCAgEArbS99qjKcnHr5G0Zc2xhDaOZnjQv
|
||||||
|
Fbiqxf/nbXt/7WaqryzpVKu7AT1ainBvuPEo7If9DhVnfF//2pGl0gbU31OU4/mr
|
||||||
|
ymQmczGEyZvOBDsZhtCif54o5OoO0BjhODNT8OWec9RT87n6RkH58MHlOi8xsPxQ
|
||||||
|
9n5U1CN/h2DyQF3aRKunEFCgtwPKWSjG+J/TAI9i0aSENXPiR8wjTrjg79s8Ehuj
|
||||||
|
NN8Wk6rKLU3sepG3GIMID5vLsVa2t9xqn562sP95Ee+Xp2YX3z7oYK99QCJdzacw
|
||||||
|
alhMHob1GCEKjDyxsD2IFRi7Dysiutfyzy3pMo6NALxFrwKVhWX0L4zVFIsI6JlV
|
||||||
|
dK8dHmDk0MRSqgB9sWXvEfSTXADEe8rncFSFpFz4Z8RNLmn5YSzQJzokNn41DUCP
|
||||||
|
dZTlTkcGTqvn5NqoY4sOV8rkFbgmTcqyijV/sebPjxCbJNcNmaSWa9FJ5IjRTpzM
|
||||||
|
38wLmxn+eKGK68n2JB3P7JP6LtsBShQEpXAF3rFfyNsP1bjquvGZVSjV8w/UwPE4
|
||||||
|
kV5eq3j3D4913Zfxvzjp6PEmhStG0EQtIXvx/TRoYpaNWypIgZdbkZQp1HUIQL15
|
||||||
|
D2Web4nazP3so1FC3ZgbrJZ2ozoadjLMp49NcSFdh+WRyVKuo0DIqR0zaiAzzf2D
|
||||||
|
G1q7TLKimM3XBMUCAwEAAaNIMEYwCQYDVR0TBAIwADALBgNVHQ8EBAMCBeAwLAYD
|
||||||
|
VR0RBCUwI4IJbG9jYWxob3N0hwR/AAABhxAAAAAAAAAAAAAAAAAAAAABMA0GCSqG
|
||||||
|
SIb3DQEBCwUAA4ICAQAvC5M1JFc21WVSLPvE2iVbt4HmirO3EENdDqs+rTYG5VJG
|
||||||
|
iE5ZuI6h/LjS5ptTfKovXQKaMr3pwp1pLMd/9q+6ZR1Hs9Z2wF6OZan4sb0uT32Y
|
||||||
|
1KGlj86QMiiSLdrJ/1Z9JHskHYNCep1ZTsUhGk0qqiNv+G3K2y7ZpvrT/xlnYMth
|
||||||
|
KLTuSVUwM8BBEPrCRLoXuaEy0LnvMvMVepIfP8tnMIL6zqmj3hXMPe4r4OFV/C5o
|
||||||
|
XX25bC7GyuPWIRYn2OWP92J1CODZD1rGRoDtmvqrQpHdeX9RYcKH0ZLZoIf5L3Hf
|
||||||
|
pPUtVkw3QGtjvKeG3b9usxaV9Od2Z08vKKk1PRkXFe8gqaeyicK7YVIOMTSuspAf
|
||||||
|
JeJEHns6Hg61Exbo7GwdX76xlmQ/Z43E9BPHKgLyZ9WuJ0cysqN4aCyvS9yws9to
|
||||||
|
ki7iMZqJUsmE2o09n9VaEsX6uQANZtLjI9wf+IgJuueDTNrkzQkhU7pbaPMsSG40
|
||||||
|
AgGY/y4BR0H8sbhNnhqtZH7RcXV9VCJoPBAe+YiuXRiXyZHWxwBRyBE3e7g4MKHg
|
||||||
|
hrWtaWUAs7gbavHwjqgU63iVItDSk7t4fCiEyObjK09AaNf2DjjaSGf8YGza4bNy
|
||||||
|
BjYinYJ6/eX//gp+abqfocFbBP7D9zRDgMIbVmX/Ey6TghKiLkZOdbzcpO4Wgg==
|
||||||
|
-----END CERTIFICATE-----
|
||||||
172
tests/testserver/index.ts
Normal file
172
tests/testserver/index.ts
Normal file
@@ -0,0 +1,172 @@
|
|||||||
|
/**
|
||||||
|
* Copyright 2017 Google Inc. All rights reserved.
|
||||||
|
* Modifications copyright (c) Microsoft Corporation.
|
||||||
|
*
|
||||||
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
* you may not use this file except in compliance with the License.
|
||||||
|
* You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing, software
|
||||||
|
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
* See the License for the specific language governing permissions and
|
||||||
|
* limitations under the License.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import fs from 'fs';
|
||||||
|
import url from 'node:url';
|
||||||
|
import http from 'http';
|
||||||
|
import https from 'https';
|
||||||
|
import path from 'path';
|
||||||
|
import debug from 'debug';
|
||||||
|
|
||||||
|
const fulfillSymbol = Symbol('fulfil callback');
|
||||||
|
const rejectSymbol = Symbol('reject callback');
|
||||||
|
|
||||||
|
// NOTE: Can be removed when we drop Node.js 18 support and changed to import.meta.filename.
|
||||||
|
const __filename = url.fileURLToPath(import.meta.url);
|
||||||
|
|
||||||
|
export class TestServer {
|
||||||
|
private _server: http.Server;
|
||||||
|
readonly debugServer: any;
|
||||||
|
private _routes = new Map<string, (request: http.IncomingMessage, response: http.ServerResponse) => any>();
|
||||||
|
private _csp = new Map<string, string>();
|
||||||
|
private _extraHeaders = new Map<string, object>();
|
||||||
|
private _requestSubscribers = new Map<string, Promise<any>>();
|
||||||
|
readonly PORT: number;
|
||||||
|
readonly PREFIX: string;
|
||||||
|
readonly CROSS_PROCESS_PREFIX: string;
|
||||||
|
readonly HELLO_WORLD: string;
|
||||||
|
|
||||||
|
static async create(port: number): Promise<TestServer> {
|
||||||
|
const server = new TestServer(port);
|
||||||
|
await new Promise(x => server._server.once('listening', x));
|
||||||
|
return server;
|
||||||
|
}
|
||||||
|
|
||||||
|
static async createHTTPS(port: number): Promise<TestServer> {
|
||||||
|
const server = new TestServer(port, {
|
||||||
|
key: await fs.promises.readFile(path.join(path.dirname(__filename), 'key.pem')),
|
||||||
|
cert: await fs.promises.readFile(path.join(path.dirname(__filename), 'cert.pem')),
|
||||||
|
passphrase: 'aaaa',
|
||||||
|
});
|
||||||
|
await new Promise(x => server._server.once('listening', x));
|
||||||
|
return server;
|
||||||
|
}
|
||||||
|
|
||||||
|
constructor(port: number, sslOptions?: object) {
|
||||||
|
if (sslOptions)
|
||||||
|
this._server = https.createServer(sslOptions, this._onRequest.bind(this));
|
||||||
|
else
|
||||||
|
this._server = http.createServer(this._onRequest.bind(this));
|
||||||
|
this._server.listen(port);
|
||||||
|
this.debugServer = debug('pw:testserver');
|
||||||
|
|
||||||
|
const cross_origin = '127.0.0.1';
|
||||||
|
const same_origin = 'localhost';
|
||||||
|
const protocol = sslOptions ? 'https' : 'http';
|
||||||
|
this.PORT = port;
|
||||||
|
this.PREFIX = `${protocol}://${same_origin}:${port}/`;
|
||||||
|
this.CROSS_PROCESS_PREFIX = `${protocol}://${cross_origin}:${port}/`;
|
||||||
|
this.HELLO_WORLD = `${this.PREFIX}hello-world`;
|
||||||
|
}
|
||||||
|
|
||||||
|
setCSP(path: string, csp: string) {
|
||||||
|
this._csp.set(path, csp);
|
||||||
|
}
|
||||||
|
|
||||||
|
setExtraHeaders(path: string, object: Record<string, string>) {
|
||||||
|
this._extraHeaders.set(path, object);
|
||||||
|
}
|
||||||
|
|
||||||
|
async stop() {
|
||||||
|
this.reset();
|
||||||
|
await new Promise(x => this._server.close(x));
|
||||||
|
}
|
||||||
|
|
||||||
|
route(path: string, handler: (request: http.IncomingMessage, response: http.ServerResponse) => any) {
|
||||||
|
this._routes.set(path, handler);
|
||||||
|
}
|
||||||
|
|
||||||
|
setContent(path: string, content: string, mimeType: string) {
|
||||||
|
this.route(path, (req, res) => {
|
||||||
|
res.writeHead(200, { 'Content-Type': mimeType });
|
||||||
|
res.end(mimeType === 'text/html' ? `<!DOCTYPE html>${content}` : content);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
redirect(from: string, to: string) {
|
||||||
|
this.route(from, (req, res) => {
|
||||||
|
const headers = this._extraHeaders.get(req.url!) || {};
|
||||||
|
res.writeHead(302, { ...headers, location: to });
|
||||||
|
res.end();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
waitForRequest(path: string): Promise<http.IncomingMessage> {
|
||||||
|
let promise = this._requestSubscribers.get(path);
|
||||||
|
if (promise)
|
||||||
|
return promise;
|
||||||
|
let fulfill, reject;
|
||||||
|
promise = new Promise((f, r) => {
|
||||||
|
fulfill = f;
|
||||||
|
reject = r;
|
||||||
|
});
|
||||||
|
promise[fulfillSymbol] = fulfill;
|
||||||
|
promise[rejectSymbol] = reject;
|
||||||
|
this._requestSubscribers.set(path, promise);
|
||||||
|
return promise;
|
||||||
|
}
|
||||||
|
|
||||||
|
reset() {
|
||||||
|
this._routes.clear();
|
||||||
|
this._csp.clear();
|
||||||
|
this._extraHeaders.clear();
|
||||||
|
this._server.closeAllConnections();
|
||||||
|
const error = new Error('Static Server has been reset');
|
||||||
|
for (const subscriber of this._requestSubscribers.values())
|
||||||
|
subscriber[rejectSymbol].call(null, error);
|
||||||
|
this._requestSubscribers.clear();
|
||||||
|
|
||||||
|
this.setContent('/favicon.ico', '', 'image/x-icon');
|
||||||
|
|
||||||
|
this.setContent('/', ``, 'text/html');
|
||||||
|
|
||||||
|
this.setContent('/hello-world', `
|
||||||
|
<title>Title</title>
|
||||||
|
<body>Hello, world!</body>
|
||||||
|
`, 'text/html');
|
||||||
|
}
|
||||||
|
|
||||||
|
_onRequest(request: http.IncomingMessage, response: http.ServerResponse) {
|
||||||
|
request.on('error', error => {
|
||||||
|
if ((error as any).code === 'ECONNRESET')
|
||||||
|
response.end();
|
||||||
|
else
|
||||||
|
throw error;
|
||||||
|
});
|
||||||
|
(request as any).postBody = new Promise(resolve => {
|
||||||
|
const chunks: Buffer[] = [];
|
||||||
|
request.on('data', chunk => {
|
||||||
|
chunks.push(chunk);
|
||||||
|
});
|
||||||
|
request.on('end', () => resolve(Buffer.concat(chunks)));
|
||||||
|
});
|
||||||
|
const path = request.url || '/';
|
||||||
|
this.debugServer(`request ${request.method} ${path}`);
|
||||||
|
// Notify request subscriber.
|
||||||
|
if (this._requestSubscribers.has(path)) {
|
||||||
|
this._requestSubscribers.get(path)![fulfillSymbol].call(null, request);
|
||||||
|
this._requestSubscribers.delete(path);
|
||||||
|
}
|
||||||
|
const handler = this._routes.get(path);
|
||||||
|
if (handler) {
|
||||||
|
handler.call(null, request, response);
|
||||||
|
} else {
|
||||||
|
response.writeHead(404);
|
||||||
|
response.end();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
52
tests/testserver/key.pem
Normal file
52
tests/testserver/key.pem
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
-----BEGIN PRIVATE KEY-----
|
||||||
|
MIIJQgIBADANBgkqhkiG9w0BAQEFAASCCSwwggkoAgEAAoICAQCttL32qMpycevk
|
||||||
|
bRlzbGENo5meNC8VuKrF/+dte3/tZqqvLOlUq7sBPVqKcG+48Sjsh/0OFWd8X//a
|
||||||
|
kaXSBtTfU5Tj+avKZCZzMYTJm84EOxmG0KJ/nijk6g7QGOE4M1Pw5Z5z1FPzufpG
|
||||||
|
QfnwweU6LzGw/FD2flTUI3+HYPJAXdpEq6cQUKC3A8pZKMb4n9MAj2LRpIQ1c+JH
|
||||||
|
zCNOuODv2zwSG6M03xaTqsotTex6kbcYgwgPm8uxVra33Gqfnraw/3kR75enZhff
|
||||||
|
Puhgr31AIl3NpzBqWEwehvUYIQqMPLGwPYgVGLsPKyK61/LPLekyjo0AvEWvApWF
|
||||||
|
ZfQvjNUUiwjomVV0rx0eYOTQxFKqAH2xZe8R9JNcAMR7yudwVIWkXPhnxE0uaflh
|
||||||
|
LNAnOiQ2fjUNQI91lOVORwZOq+fk2qhjiw5XyuQVuCZNyrKKNX+x5s+PEJsk1w2Z
|
||||||
|
pJZr0UnkiNFOnMzfzAubGf54oYrryfYkHc/sk/ou2wFKFASlcAXesV/I2w/VuOq6
|
||||||
|
8ZlVKNXzD9TA8TiRXl6rePcPj3Xdl/G/OOno8SaFK0bQRC0he/H9NGhilo1bKkiB
|
||||||
|
l1uRlCnUdQhAvXkPZZ5vidrM/eyjUULdmBuslnajOhp2Msynj01xIV2H5ZHJUq6j
|
||||||
|
QMipHTNqIDPN/YMbWrtMsqKYzdcExQIDAQABAoICAGqXttpdyZ1g+vg5WpzRrNzJ
|
||||||
|
v8KtExepMmI+Hq24U1BC6AqG7MfgeejQ1XaOeIBsvEgpSsgRqmdQIZjmN3Mibg59
|
||||||
|
I6ih1SFlQ5L8mBd/XHSML6Xi8VSOoVmXp29bVRk/pgr1XL6HVN0DCumCIvXyhc+m
|
||||||
|
lj+dFbGs5DEpd2CDxSRqcz4gd2wzjevAj7MWqsJ2kOyPEHzFD7wdWIXmZuQv3xhQ
|
||||||
|
2BPkkcon+5qx+07BupOcR1brUU8Cs4QnSgiZYXSB2GnU215+P/mhVJTR7ZcnGRz5
|
||||||
|
+cXxCmy3sj4pYs1juS1FMWSM3azUeDVeqvks+vrXmXpEr5H79mbmlwo8/hMPwNDO
|
||||||
|
07HRZwa8T01aT9EYVm0lIOYjMF/2f6j6cu2apJtjXICOksR2HefRBVXQirOxRHma
|
||||||
|
9XAYfNkZ/2164ZbgFmJv9khFnegPEuth9tLVdFIeGSmsG0aX9tH63zGT2NROyyLc
|
||||||
|
QXPqsDl2CxCYPRs2oiGkM9dnfP1wAOp96sq42GIuN7ykfqfRnwAIvvnLKvyCq1vR
|
||||||
|
pIno3CIX6vnzt+1/Hrmv13b0L6pJPitpXwKWHv9zJKBTpN8HEzP3Qmth2Ef60/7/
|
||||||
|
CBo1PVTd1A6zcU7816flg7SCY+Vk+OxVHV3dGBIIqN9SfrQ8BPcOl6FNV5Anbrnv
|
||||||
|
CpSw+LzH9n5xympDnk0BAoIBAQDjenvDfCnrNVeqx8+sYaYey4/WPVLXOQhREvRY
|
||||||
|
oOtX9eqlNSi20+Wl+iuXmyj8wdHrDET7rfjCbpDQ7u105yzLw4gy4qIRDKZ1nE45
|
||||||
|
YX+tm8mZgBqRnTp0DoGOArqmp3IKXJtUYmpbTz9tOfY7Usb1o1epb4winEB+Pl+8
|
||||||
|
mgXOEo8xvWBzKeRA7tE73V64Mwbvbo9Ff2EguhXweQP29yBkEjT4iViayuHUmyPt
|
||||||
|
hOVSMj2oFQuQGPdhAk7nUXojSGK/Zas/AGpH9CHH9De0h4m08vd3oM4vj0HwzgjU
|
||||||
|
Co9aRa9SAH7EiaocOTcjDRPxWdZPHhxmrVRIYlF0MNmOAkXJAoIBAQDDfEqu4sNi
|
||||||
|
pq74VXVatQqhzCILZo+o48bdgEjF7mF99mqPj8rwIDrEoEriDK861kenLc3vWKRY
|
||||||
|
5wh1iX3S896re9kUMoxx6p4heYTcsOJ9BbkcpT8bJPZx9gBJb4jJENeVf1exf6sG
|
||||||
|
RhFnulpzReRRaUjX2yAkyUPfc8YcUt+Nalrg+2W0fzeLCUpABCAcj2B1Vv7qRZHj
|
||||||
|
oEtlCV5Nz+iMhrwIa16g9c8wGt5DZb4PI+VIJ6EYkdsjhgqIF0T/wDq9/habGBPo
|
||||||
|
mHN+/DX3hCJWN2QgoVGJskHGt0zDMgiEgXfLZ2Grl02vQtq+mW2O2vGVeUd9Y5Ew
|
||||||
|
RUiY4bSRTrUdAoIBAHxL1wiP9c/By+9TUtScXssA681ioLtdPIAgXUd4VmAvzVEM
|
||||||
|
ZPzRd/BjbCJg89p4hZ1rjN4Ax6ZmB9dCVpnEH6QPaYJ0d53dTa+CAvQzpDJWp6eq
|
||||||
|
adobEW+M5ZmVQCwD3rpus6k+RWMzQDMMstDjgDeEU0gP3YCj5FGW/3TsrDNXzMqe
|
||||||
|
8e67ey9Hzyho43K+3xFBViPhYE8jnw1Q8quliRtlH3CWi8W5CgDD7LPCJBPvw+Tt
|
||||||
|
6u2H1tQ5EKgwyw4wZVSz1wiLz4cVjMfXWADa9pHbGQFS6pbuLlfIHObQBliLLysd
|
||||||
|
ficiGcNmOAx8/uKn9gQxLc+k8iLDJkLY1mdUMpECggEAJLl87k37ltTpmg2z9k58
|
||||||
|
qNjIrIugAYKJIaOwCD84YYmhi0bgQSxM3hOe/ciUQuFupKGeRpDIj0sX87zYvoDC
|
||||||
|
HEUwCvNUHzKMco15wFwasJIarJ7+tALFqbMlaqZhdCSN27AIsXfikVMogewoge9n
|
||||||
|
bUPyQ1sPNtn4vknptfh7tv18BTg1aytbK+ua31vnDHaDEIg/a5OWTMUYZOrVpJii
|
||||||
|
f4PwX0SMioCjY84oY1EB26ZKtLt9MDh2ir3rzJVSiRl776WEaa6kTtYVHI4VNWLF
|
||||||
|
cJ0HWnnz74JliQd2jFUh9IK+FqBdYPcTyREuNxBr3KKVMBeQrqW96OubL913JrU6
|
||||||
|
oQKCAQEA0yzORUouT0yleWs7RmzBlT9OLD/3cBYJMf/r1F8z8OQjB8fU1jKbO1Cs
|
||||||
|
q4l+o9FmI+eHkgc3xbEG0hahOFWm/hTTli9vzksxurgdawZELThRkK33uTU9pKla
|
||||||
|
Okqx3Ru/iMOW2+DQUx9UB+jK+hSAgq4gGqLeJVyaBerIdLQLlvqxrwSxjvvj+wJC
|
||||||
|
Y66mgRzdCi6VDF1vV0knCrQHK6tRwcPozu/k4zjJzvdbMJnKEy2S7Vh6vO8lEPJm
|
||||||
|
MQtaHPpmz+F4z14b9unNIiSbHO60Q4O+BwIBCzxApQQbFg63vBLYYwEMRd7hh92s
|
||||||
|
ZkZVSOEp+sYBf/tmptlKr49nO+dTjQ==
|
||||||
|
-----END PRIVATE KEY-----
|
||||||
19
tests/testserver/san.cnf
Normal file
19
tests/testserver/san.cnf
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
# openssl req -new -x509 -days 3650 -key key.pem -out cert.pem -config san.cnf -extensions v3_req
|
||||||
|
|
||||||
|
[req]
|
||||||
|
distinguished_name = req_distinguished_name
|
||||||
|
req_extensions = v3_req
|
||||||
|
prompt = no
|
||||||
|
|
||||||
|
[req_distinguished_name]
|
||||||
|
CN = playwright-test
|
||||||
|
|
||||||
|
[v3_req]
|
||||||
|
basicConstraints = CA:FALSE
|
||||||
|
keyUsage = nonRepudiation, digitalSignature, keyEncipherment
|
||||||
|
subjectAltName = @alt_names
|
||||||
|
|
||||||
|
[alt_names]
|
||||||
|
DNS.1 = localhost
|
||||||
|
IP.1 = 127.0.0.1
|
||||||
|
IP.2 = ::1
|
||||||
35
tests/trace.spec.ts
Normal file
35
tests/trace.spec.ts
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
/**
|
||||||
|
* 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 { test, expect } from './fixtures.js';
|
||||||
|
|
||||||
|
test('check that trace is saved', async ({ startClient, server, mcpMode }, testInfo) => {
|
||||||
|
const outputDir = testInfo.outputPath('output');
|
||||||
|
|
||||||
|
const { client } = await startClient({
|
||||||
|
args: ['--save-trace', `--output-dir=${outputDir}`],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(await client.callTool({
|
||||||
|
name: 'browser_navigate',
|
||||||
|
arguments: { url: server.HELLO_WORLD },
|
||||||
|
})).toContainTextContent(`Navigate to http://localhost`);
|
||||||
|
|
||||||
|
expect(fs.existsSync(path.join(outputDir, 'traces', 'trace.trace'))).toBeTruthy();
|
||||||
|
});
|
||||||
85
tests/wait.spec.ts
Normal file
85
tests/wait.spec.ts
Normal file
@@ -0,0 +1,85 @@
|
|||||||
|
/**
|
||||||
|
* Copyright (c) Microsoft Corporation.
|
||||||
|
*
|
||||||
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
* you may not use this file except in compliance with the License.
|
||||||
|
* You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing, software
|
||||||
|
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
* See the License for the specific language governing permissions and
|
||||||
|
* limitations under the License.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { test, expect } from './fixtures.js';
|
||||||
|
|
||||||
|
test('browser_wait_for(text)', async ({ client, server }) => {
|
||||||
|
server.setContent('/', `
|
||||||
|
<script>
|
||||||
|
function update() {
|
||||||
|
setTimeout(() => {
|
||||||
|
document.querySelector('div').textContent = 'Text to appear';
|
||||||
|
}, 1000);
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
<body>
|
||||||
|
<button onclick="update()">Click me</button>
|
||||||
|
<div>Text to disappear</div>
|
||||||
|
</body>
|
||||||
|
`, 'text/html');
|
||||||
|
|
||||||
|
await client.callTool({
|
||||||
|
name: 'browser_navigate',
|
||||||
|
arguments: { url: server.PREFIX },
|
||||||
|
});
|
||||||
|
|
||||||
|
await client.callTool({
|
||||||
|
name: 'browser_click',
|
||||||
|
arguments: {
|
||||||
|
element: 'Click me',
|
||||||
|
ref: 'e2',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(await client.callTool({
|
||||||
|
name: 'browser_wait_for',
|
||||||
|
arguments: { text: 'Text to appear' },
|
||||||
|
})).toContainTextContent(`- generic [ref=e3]: Text to appear`);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('browser_wait_for(textGone)', async ({ client, server }) => {
|
||||||
|
server.setContent('/', `
|
||||||
|
<script>
|
||||||
|
function update() {
|
||||||
|
setTimeout(() => {
|
||||||
|
document.querySelector('div').textContent = 'Text to appear';
|
||||||
|
}, 1000);
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
<body>
|
||||||
|
<button onclick="update()">Click me</button>
|
||||||
|
<div>Text to disappear</div>
|
||||||
|
</body>
|
||||||
|
`, 'text/html');
|
||||||
|
|
||||||
|
await client.callTool({
|
||||||
|
name: 'browser_navigate',
|
||||||
|
arguments: { url: server.PREFIX },
|
||||||
|
});
|
||||||
|
|
||||||
|
await client.callTool({
|
||||||
|
name: 'browser_click',
|
||||||
|
arguments: {
|
||||||
|
element: 'Click me',
|
||||||
|
ref: 'e2',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(await client.callTool({
|
||||||
|
name: 'browser_wait_for',
|
||||||
|
arguments: { textGone: 'Text to disappear' },
|
||||||
|
})).toContainTextContent(`- generic [ref=e3]: Text to appear`);
|
||||||
|
});
|
||||||
38
tests/webdriver.spec.ts
Normal file
38
tests/webdriver.spec.ts
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
/**
|
||||||
|
* Copyright (c) Microsoft Corporation.
|
||||||
|
*
|
||||||
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
* you may not use this file except in compliance with the License.
|
||||||
|
* You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing, software
|
||||||
|
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
* See the License for the specific language governing permissions and
|
||||||
|
* limitations under the License.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { test, expect } from './fixtures.js';
|
||||||
|
|
||||||
|
test('do not falsely advertise user agent as a test driver', async ({ client, server, mcpBrowser }) => {
|
||||||
|
test.skip(mcpBrowser === 'firefox');
|
||||||
|
test.skip(mcpBrowser === 'webkit');
|
||||||
|
server.route('/', (req, res) => {
|
||||||
|
res.writeHead(200, { 'Content-Type': 'text/html' });
|
||||||
|
res.end(`
|
||||||
|
<body></body>
|
||||||
|
<script>
|
||||||
|
document.body.textContent = 'webdriver: ' + navigator.webdriver;
|
||||||
|
</script>
|
||||||
|
`);
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(await client.callTool({
|
||||||
|
name: 'browser_navigate',
|
||||||
|
arguments: {
|
||||||
|
url: server.PREFIX,
|
||||||
|
},
|
||||||
|
})).toContainTextContent('webdriver: false');
|
||||||
|
});
|
||||||
4
tsconfig.all.json
Normal file
4
tsconfig.all.json
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
{
|
||||||
|
"extends": "./tsconfig.json",
|
||||||
|
"include": ["**/*.ts", "**/*.js"],
|
||||||
|
}
|
||||||
@@ -1,12 +1,13 @@
|
|||||||
{
|
{
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"target": "ESNext",
|
"target": "ESNext",
|
||||||
"skipLibCheck": true,
|
|
||||||
"esModuleInterop": true,
|
"esModuleInterop": true,
|
||||||
"moduleResolution": "node",
|
"moduleResolution": "nodenext",
|
||||||
"strict": true,
|
"strict": true,
|
||||||
"module": "CommonJS",
|
"module": "NodeNext",
|
||||||
"outDir": "./lib"
|
"rootDir": "src",
|
||||||
|
"outDir": "./lib",
|
||||||
|
"resolveJsonModule": true
|
||||||
},
|
},
|
||||||
"include": [
|
"include": [
|
||||||
"src",
|
"src",
|
||||||
|
|||||||
6
utils/generate-links.js
Normal file
6
utils/generate-links.js
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
const config = JSON.stringify({ name: 'playwright', command: 'npx', args: ["@playwright/mcp@latest"] });
|
||||||
|
const urlForWebsites = `vscode:mcp/install?${encodeURIComponent(config)}`;
|
||||||
|
// Github markdown does not allow linking to `vscode:` directly, so you can use our redirect:
|
||||||
|
const urlForGithub = `https://insiders.vscode.dev/redirect?url=${encodeURIComponent(urlForWebsites)}`;
|
||||||
|
|
||||||
|
console.log(urlForGithub);
|
||||||
194
utils/update-readme.js
Normal file
194
utils/update-readme.js
Normal file
@@ -0,0 +1,194 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
/**
|
||||||
|
* Copyright (c) Microsoft Corporation.
|
||||||
|
*
|
||||||
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
* you may not use this file except in compliance with the License.
|
||||||
|
* You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing, software
|
||||||
|
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
* See the License for the specific language governing permissions and
|
||||||
|
* limitations under the License.
|
||||||
|
*/
|
||||||
|
// @ts-check
|
||||||
|
|
||||||
|
import fs from 'node:fs'
|
||||||
|
import path from 'node:path'
|
||||||
|
import url from 'node:url'
|
||||||
|
import zodToJsonSchema from 'zod-to-json-schema'
|
||||||
|
|
||||||
|
import commonTools from '../lib/tools/common.js';
|
||||||
|
import consoleTools from '../lib/tools/console.js';
|
||||||
|
import dialogsTools from '../lib/tools/dialogs.js';
|
||||||
|
import filesTools from '../lib/tools/files.js';
|
||||||
|
import installTools from '../lib/tools/install.js';
|
||||||
|
import keyboardTools from '../lib/tools/keyboard.js';
|
||||||
|
import navigateTools from '../lib/tools/navigate.js';
|
||||||
|
import networkTools from '../lib/tools/network.js';
|
||||||
|
import pdfTools from '../lib/tools/pdf.js';
|
||||||
|
import snapshotTools from '../lib/tools/snapshot.js';
|
||||||
|
import tabsTools from '../lib/tools/tabs.js';
|
||||||
|
import screenshotTools from '../lib/tools/screenshot.js';
|
||||||
|
import testTools from '../lib/tools/testing.js';
|
||||||
|
import visionTools from '../lib/tools/vision.js';
|
||||||
|
import waitTools from '../lib/tools/wait.js';
|
||||||
|
import { execSync } from 'node:child_process';
|
||||||
|
|
||||||
|
const categories = {
|
||||||
|
'Interactions': [
|
||||||
|
...snapshotTools,
|
||||||
|
...keyboardTools(true),
|
||||||
|
...waitTools(true),
|
||||||
|
...filesTools(true),
|
||||||
|
...dialogsTools(true),
|
||||||
|
],
|
||||||
|
'Navigation': [
|
||||||
|
...navigateTools(true),
|
||||||
|
],
|
||||||
|
'Resources': [
|
||||||
|
...screenshotTools,
|
||||||
|
...pdfTools,
|
||||||
|
...networkTools,
|
||||||
|
...consoleTools,
|
||||||
|
],
|
||||||
|
'Utilities': [
|
||||||
|
...installTools,
|
||||||
|
...commonTools(true),
|
||||||
|
],
|
||||||
|
'Tabs': [
|
||||||
|
...tabsTools(true),
|
||||||
|
],
|
||||||
|
'Testing': [
|
||||||
|
...testTools,
|
||||||
|
],
|
||||||
|
'Vision mode': [
|
||||||
|
...visionTools,
|
||||||
|
...keyboardTools(),
|
||||||
|
...waitTools(false),
|
||||||
|
...filesTools(false),
|
||||||
|
...dialogsTools(false),
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
// NOTE: Can be removed when we drop Node.js 18 support and changed to import.meta.filename.
|
||||||
|
const __filename = url.fileURLToPath(import.meta.url);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {import('../src/tools/tool.js').ToolSchema<any>} tool
|
||||||
|
* @returns {string[]}
|
||||||
|
*/
|
||||||
|
function formatToolForReadme(tool) {
|
||||||
|
const lines = /** @type {string[]} */ ([]);
|
||||||
|
lines.push(`<!-- NOTE: This has been generated via ${path.basename(__filename)} -->`);
|
||||||
|
lines.push(``);
|
||||||
|
lines.push(`- **${tool.name}**`);
|
||||||
|
lines.push(` - Title: ${tool.title}`);
|
||||||
|
lines.push(` - Description: ${tool.description}`);
|
||||||
|
|
||||||
|
const inputSchema = /** @type {any} */ (zodToJsonSchema(tool.inputSchema || {}));
|
||||||
|
const requiredParams = inputSchema.required || [];
|
||||||
|
if (inputSchema.properties && Object.keys(inputSchema.properties).length) {
|
||||||
|
lines.push(` - Parameters:`);
|
||||||
|
Object.entries(inputSchema.properties).forEach(([name, param]) => {
|
||||||
|
const optional = !requiredParams.includes(name);
|
||||||
|
const meta = /** @type {string[]} */ ([]);
|
||||||
|
if (param.type)
|
||||||
|
meta.push(param.type);
|
||||||
|
if (optional)
|
||||||
|
meta.push('optional');
|
||||||
|
lines.push(` - \`${name}\` ${meta.length ? `(${meta.join(', ')})` : ''}: ${param.description}`);
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
lines.push(` - Parameters: None`);
|
||||||
|
}
|
||||||
|
lines.push(` - Read-only: **${tool.type === 'readOnly'}**`);
|
||||||
|
lines.push('');
|
||||||
|
return lines;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {string} content
|
||||||
|
* @param {string} startMarker
|
||||||
|
* @param {string} endMarker
|
||||||
|
* @param {string[]} generatedLines
|
||||||
|
* @returns {Promise<string>}
|
||||||
|
*/
|
||||||
|
async function updateSection(content, startMarker, endMarker, generatedLines) {
|
||||||
|
const startMarkerIndex = content.indexOf(startMarker);
|
||||||
|
const endMarkerIndex = content.indexOf(endMarker);
|
||||||
|
if (startMarkerIndex === -1 || endMarkerIndex === -1)
|
||||||
|
throw new Error('Markers for generated section not found in README');
|
||||||
|
|
||||||
|
return [
|
||||||
|
content.slice(0, startMarkerIndex + startMarker.length),
|
||||||
|
'',
|
||||||
|
generatedLines.join('\n'),
|
||||||
|
'',
|
||||||
|
content.slice(endMarkerIndex),
|
||||||
|
].join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {string} content
|
||||||
|
* @returns {Promise<string>}
|
||||||
|
*/
|
||||||
|
async function updateTools(content) {
|
||||||
|
console.log('Loading tool information from compiled modules...');
|
||||||
|
|
||||||
|
const totalTools = Object.values(categories).flat().length;
|
||||||
|
console.log(`Found ${totalTools} tools`);
|
||||||
|
|
||||||
|
const generatedLines = /** @type {string[]} */ ([]);
|
||||||
|
for (const [category, categoryTools] of Object.entries(categories)) {
|
||||||
|
generatedLines.push(`<details>\n<summary><b>${category}</b></summary>`);
|
||||||
|
generatedLines.push('');
|
||||||
|
for (const tool of categoryTools)
|
||||||
|
generatedLines.push(...formatToolForReadme(tool.schema));
|
||||||
|
generatedLines.push(`</details>`);
|
||||||
|
generatedLines.push('');
|
||||||
|
}
|
||||||
|
|
||||||
|
const startMarker = `<!--- Tools generated by ${path.basename(__filename)} -->`;
|
||||||
|
const endMarker = `<!--- End of tools generated section -->`;
|
||||||
|
return updateSection(content, startMarker, endMarker, generatedLines);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {string} content
|
||||||
|
* @returns {Promise<string>}
|
||||||
|
*/
|
||||||
|
async function updateOptions(content) {
|
||||||
|
console.log('Listing options...');
|
||||||
|
const output = execSync('node cli.js --help');
|
||||||
|
const lines = output.toString().split('\n');
|
||||||
|
const firstLine = lines.findIndex(line => line.includes('--version'));
|
||||||
|
lines.splice(0, firstLine + 1);
|
||||||
|
const lastLine = lines.findIndex(line => line.includes('--help'));
|
||||||
|
lines.splice(lastLine);
|
||||||
|
const startMarker = `<!--- Options generated by ${path.basename(__filename)} -->`;
|
||||||
|
const endMarker = `<!--- End of options generated section -->`;
|
||||||
|
return updateSection(content, startMarker, endMarker, [
|
||||||
|
'```',
|
||||||
|
'> npx @playwright/mcp@latest --help',
|
||||||
|
...lines,
|
||||||
|
'```',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function updateReadme() {
|
||||||
|
const readmePath = path.join(path.dirname(__filename), '..', 'README.md');
|
||||||
|
const readmeContent = await fs.promises.readFile(readmePath, 'utf-8');
|
||||||
|
const withTools = await updateTools(readmeContent);
|
||||||
|
const withOptions = await updateOptions(withTools);
|
||||||
|
await fs.promises.writeFile(readmePath, withOptions, 'utf-8');
|
||||||
|
console.log('README updated successfully');
|
||||||
|
}
|
||||||
|
|
||||||
|
updateReadme().catch(err => {
|
||||||
|
console.error('Error updating README:', err);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user