Compare commits
67 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 |
53
.github/workflows/ci.yml
vendored
53
.github/workflows/ci.yml
vendored
@@ -30,31 +30,56 @@ jobs:
|
|||||||
matrix:
|
matrix:
|
||||||
os: [ubuntu-latest, macos-latest, windows-latest]
|
os: [ubuntu-latest, macos-latest, windows-latest]
|
||||||
runs-on: ${{ matrix.os }}
|
runs-on: ${{ matrix.os }}
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
|
- name: Use Node.js 18
|
||||||
|
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
|
||||||
|
run: npm run build
|
||||||
|
- name: Run tests
|
||||||
|
run: npm test
|
||||||
|
|
||||||
|
test_docker:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- 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
|
||||||
|
|
||||||
- name: Playwright install
|
- name: Playwright install
|
||||||
run: npx playwright install --with-deps
|
run: npx playwright install --with-deps chromium
|
||||||
|
|
||||||
- name: Install MS Edge
|
|
||||||
if: ${{ matrix.os == 'macos-latest' }} # MS Edge is not preinstalled on macOS runners.
|
|
||||||
run: npx playwright install msedge
|
|
||||||
|
|
||||||
- name: Build
|
- name: Build
|
||||||
run: npm run build
|
run: npm run build
|
||||||
|
- name: Set up Docker Buildx
|
||||||
- name: Install Playwright browsers
|
uses: docker/setup-buildx-action@v3
|
||||||
run: npx playwright install --with-deps
|
- 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
|
- name: Run tests
|
||||||
run: npm test -- --forbid-only
|
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
|
||||||
|
|||||||
35
.github/workflows/publish.yml
vendored
35
.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
|
||||||
@@ -21,4 +21,35 @@ jobs:
|
|||||||
- run: npm run ctest
|
- 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
|
||||||
|
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
|
||||||
|
|||||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -1,6 +1,7 @@
|
|||||||
lib/
|
lib/
|
||||||
node_modules/
|
node_modules/
|
||||||
test-results/
|
test-results/
|
||||||
|
playwright-report/
|
||||||
.vscode/mcp.json
|
.vscode/mcp.json
|
||||||
|
|
||||||
.idea
|
.idea
|
||||||
|
|||||||
65
Dockerfile
65
Dockerfile
@@ -1,22 +1,69 @@
|
|||||||
FROM node:22-bookworm-slim
|
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
|
# Set the working directory
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
# Copy package.json and package-lock.json at this stage to leverage the build cache
|
RUN --mount=type=cache,target=/root/.npm,sharing=locked,id=npm-cache \
|
||||||
COPY package*.json ./
|
--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
|
||||||
|
|
||||||
# Install dependencies
|
# ------------------------------
|
||||||
RUN npm ci
|
# Builder
|
||||||
|
# ------------------------------
|
||||||
|
FROM base AS builder
|
||||||
|
|
||||||
# Install chromium and its dependencies, but only for headless mode
|
RUN --mount=type=cache,target=/root/.npm,sharing=locked,id=npm-cache \
|
||||||
RUN npx -y playwright install --with-deps --only-shell chromium
|
--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 the rest of the app
|
||||||
COPY . .
|
COPY *.json *.js *.ts .
|
||||||
|
COPY src src/
|
||||||
|
|
||||||
# Build the app
|
# Build the app
|
||||||
RUN npm run build
|
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)
|
# Run in headless and only with chromium (other browsers need more dependencies not included in this image)
|
||||||
ENTRYPOINT ["node", "cli.js", "--headless", "--browser", "chromium"]
|
ENTRYPOINT ["node", "cli.js", "--headless", "--browser", "chromium", "--no-sandbox"]
|
||||||
|
|||||||
607
README.md
607
README.md
@@ -4,25 +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
|
||||||
- Web navigation and form-filling
|
- VS Code, Cursor, Windsurf, Claude Desktop or any other MCP client
|
||||||
- Data extraction from structured content
|
|
||||||
- Automated testing driven by LLMs
|
|
||||||
- General-purpose browser interaction for agents
|
|
||||||
|
|
||||||
<!--
|
<!--
|
||||||
// Generate using:
|
// Generate using:
|
||||||
node utils/generate_links.js
|
node utils/generate-links.js
|
||||||
-->
|
-->
|
||||||
|
|
||||||
[<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)
|
### Getting started
|
||||||
|
|
||||||
### Example config
|
First, install the Playwright MCP server with your client. A typical configuration looks like this:
|
||||||
|
|
||||||
```js
|
```js
|
||||||
{
|
{
|
||||||
@@ -37,20 +34,12 @@ node utils/generate_links.js
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
### Table of Contents
|
[<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](#installation-in-vs-code)
|
|
||||||
- [Command line](#command-line)
|
|
||||||
- [User profile](#user-profile)
|
|
||||||
- [Configuration file](#configuration-file)
|
|
||||||
- [Running on Linux](#running-on-linux)
|
|
||||||
- [Docker](#docker)
|
|
||||||
- [Programmatic usage](#programmatic-usage)
|
|
||||||
- [Tool modes](#tool-modes)
|
|
||||||
|
|
||||||
### Installation in VS Code
|
<details><summary><b>Install in VS Code</b></summary>
|
||||||
|
|
||||||
You can install the Playwright MCP server using the VS Code CLI:
|
You can also install the Playwright MCP server using the VS Code CLI:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# For VS Code
|
# For VS Code
|
||||||
@@ -58,42 +47,176 @@ code --add-mcp '{"name":"playwright","command":"npx","args":["@playwright/mcp@la
|
|||||||
```
|
```
|
||||||
|
|
||||||
After installation, the Playwright MCP server will be available for use with your GitHub Copilot agent in VS Code.
|
After installation, the Playwright MCP server will be available for use with your GitHub Copilot agent in VS Code.
|
||||||
|
</details>
|
||||||
|
|
||||||
### Command line
|
<details>
|
||||||
|
<summary><b>Install in Cursor</b></summary>
|
||||||
|
|
||||||
The Playwright MCP server supports the following command-line options:
|
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`.
|
||||||
|
|
||||||
- `--browser <browser>`: Browser or chrome channel to use. Possible values:
|
```js
|
||||||
- `chrome`, `firefox`, `webkit`, `msedge`
|
{
|
||||||
- Chrome channels: `chrome-beta`, `chrome-canary`, `chrome-dev`
|
"mcpServers": {
|
||||||
- Edge channels: `msedge-beta`, `msedge-canary`, `msedge-dev`
|
"playwright": {
|
||||||
- Default: `chrome`
|
"command": "npx",
|
||||||
- `--caps <caps>`: Comma-separated list of capabilities to enable, possible values: tabs, pdf, history, wait, files, install. Default is all.
|
"args": [
|
||||||
- `--cdp-endpoint <endpoint>`: CDP endpoint to connect to
|
"@playwright/mcp@latest"
|
||||||
- `--executable-path <path>`: Path to the browser executable
|
]
|
||||||
- `--headless`: Run browser in headless mode (headed by default)
|
}
|
||||||
- `--device`: Emulate mobile device
|
}
|
||||||
- `--user-data-dir <path>`: Path to the user data directory
|
}
|
||||||
- `--port <port>`: Port to listen on for SSE transport
|
```
|
||||||
- `--host <host>`: Host to bind server to. Default is localhost. Use 0.0.0.0 to bind to all interfaces.
|
</details>
|
||||||
- `--vision`: Run server that uses screenshots (Aria snapshots are used by default)
|
|
||||||
- `--config <path>`: Path to the configuration file
|
<details>
|
||||||
|
<summary><b>Install in Windsurf</b></summary>
|
||||||
|
|
||||||
|
Follow Windsuff MCP [documentation](https://docs.windsurf.com/windsurf/cascade/mcp). Use following configuration:
|
||||||
|
|
||||||
|
```js
|
||||||
|
{
|
||||||
|
"mcpServers": {
|
||||||
|
"playwright": {
|
||||||
|
"command": "npx",
|
||||||
|
"args": [
|
||||||
|
"@playwright/mcp@latest"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
</details>
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary><b>Install in Claude Desktop</b></summary>
|
||||||
|
|
||||||
|
Follow the MCP install [guide](https://modelcontextprotocol.io/quickstart/user), use following configuration:
|
||||||
|
|
||||||
|
```js
|
||||||
|
{
|
||||||
|
"mcpServers": {
|
||||||
|
"playwright": {
|
||||||
|
"command": "npx",
|
||||||
|
"args": [
|
||||||
|
"@playwright/mcp@latest"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
</details>
|
||||||
|
|
||||||
|
### 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.
|
||||||
|
--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
|
### User profile
|
||||||
|
|
||||||
Playwright MCP will launch the browser with the new profile, located at
|
You can run Playwright MCP with persistent profile like a regular browser (default), or in the isolated contexts for the testing sessions.
|
||||||
|
|
||||||
```
|
**Persistent profile**
|
||||||
- `%USERPROFILE%\AppData\Local\ms-playwright\mcp-{channel}-profile` on Windows
|
|
||||||
- `~/Library/Caches/ms-playwright/mcp-{channel}-profile` on macOS
|
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.
|
||||||
- `~/.cache/ms-playwright/mcp-{channel}-profile` on Linux
|
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
|
||||||
```
|
```
|
||||||
|
|
||||||
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.
|
**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
|
||||||
|
{
|
||||||
|
"mcpServers": {
|
||||||
|
"playwright": {
|
||||||
|
"command": "npx",
|
||||||
|
"args": [
|
||||||
|
"@playwright/mcp@latest",
|
||||||
|
"--isolated",
|
||||||
|
"--storage-state={path/to/storage.json}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
### Configuration file
|
### Configuration file
|
||||||
|
|
||||||
The Playwright MCP server can be configured using a JSON configuration file. Here's the complete configuration format:
|
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
|
```typescript
|
||||||
{
|
{
|
||||||
@@ -102,6 +225,9 @@ The Playwright MCP server can be configured using a JSON configuration file. Her
|
|||||||
// Browser type to use (chromium, firefox, or webkit)
|
// Browser type to use (chromium, firefox, or webkit)
|
||||||
browserName?: 'chromium' | 'firefox' | '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
|
// Path to user data directory for browser profile persistence
|
||||||
userDataDir?: string;
|
userDataDir?: string;
|
||||||
|
|
||||||
@@ -142,7 +268,8 @@ The Playwright MCP server can be configured using a JSON configuration file. Her
|
|||||||
'history' | // Browser history
|
'history' | // Browser history
|
||||||
'wait' | // Wait utilities
|
'wait' | // Wait utilities
|
||||||
'files' | // File handling
|
'files' | // File handling
|
||||||
'install' // Browser installation
|
'install' | // Browser installation
|
||||||
|
'testing' // Testing
|
||||||
>;
|
>;
|
||||||
|
|
||||||
// Enable vision mode (screenshots instead of accessibility snapshots)
|
// Enable vision mode (screenshots instead of accessibility snapshots)
|
||||||
@@ -151,23 +278,24 @@ The Playwright MCP server can be configured using a JSON configuration file. Her
|
|||||||
// Directory for output files
|
// Directory for output files
|
||||||
outputDir?: string;
|
outputDir?: string;
|
||||||
|
|
||||||
// Tool-specific configurations
|
// Network configuration
|
||||||
tools?: {
|
network?: {
|
||||||
browser_take_screenshot?: {
|
// List of origins to allow the browser to request. Default is to allow all. Origins matching both `allowedOrigins` and `blockedOrigins` will be blocked.
|
||||||
// Disable base64-encoded image responses
|
allowedOrigins?: string[];
|
||||||
omitBase64?: boolean;
|
|
||||||
}
|
// List of origins to block the browser to request. Origins matching both `allowedOrigins` and `blockedOrigins` will be blocked.
|
||||||
}
|
blockedOrigins?: string[];
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Do not send image responses to the client.
|
||||||
|
*/
|
||||||
|
noImageResponses?: boolean;
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
</details>
|
||||||
|
|
||||||
You can specify the configuration file using the `--config` command line option:
|
### Standalone MCP server
|
||||||
|
|
||||||
```bash
|
|
||||||
npx @playwright/mcp@latest --config path/to/config.json
|
|
||||||
```
|
|
||||||
|
|
||||||
### Running on Linux
|
|
||||||
|
|
||||||
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.
|
||||||
@@ -188,7 +316,8 @@ And then in MCP client config, set the `url` to the SSE endpoint:
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
### Docker
|
<details>
|
||||||
|
<summary><b>Docker</b></summary>
|
||||||
|
|
||||||
**NOTE:** The Docker implementation only supports headless chromium at the moment.
|
**NOTE:** The Docker implementation only supports headless chromium at the moment.
|
||||||
|
|
||||||
@@ -197,7 +326,7 @@ And then in MCP client config, set the `url` to the SSE endpoint:
|
|||||||
"mcpServers": {
|
"mcpServers": {
|
||||||
"playwright": {
|
"playwright": {
|
||||||
"command": "docker",
|
"command": "docker",
|
||||||
"args": ["run", "-i", "--rm", "--init", "mcp/playwright"]
|
"args": ["run", "-i", "--rm", "--init", "--pull=always", "mcr.microsoft.com/playwright/mcp"]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -206,30 +335,33 @@ And then in MCP client config, set the `url` to the SSE endpoint:
|
|||||||
You can build the Docker image yourself.
|
You can build the Docker image yourself.
|
||||||
|
|
||||||
```
|
```
|
||||||
docker build -t mcp/playwright .
|
docker build -t mcr.microsoft.com/playwright/mcp .
|
||||||
```
|
```
|
||||||
|
</details>
|
||||||
|
|
||||||
### Programmatic usage
|
<details>
|
||||||
|
<summary><b>Programmatic usage</b></summary>
|
||||||
|
|
||||||
```js
|
```js
|
||||||
import http from 'http';
|
import http from 'http';
|
||||||
|
|
||||||
import { createServer } from '@playwright/mcp';
|
import { createConnection } from '@playwright/mcp';
|
||||||
import { SSEServerTransport } from '@modelcontextprotocol/sdk/server/sse.js';
|
import { SSEServerTransport } from '@modelcontextprotocol/sdk/server/sse.js';
|
||||||
|
|
||||||
http.createServer(async (req, res) => {
|
http.createServer(async (req, res) => {
|
||||||
// ...
|
// ...
|
||||||
|
|
||||||
// Creates a headless Playwright MCP server with SSE transport
|
// Creates a headless Playwright MCP server with SSE transport
|
||||||
const mcpServer = await createServer({ headless: true });
|
const connection = await createConnection({ browser: { launchOptions: { headless: true } } });
|
||||||
const transport = new SSEServerTransport('/messages', res);
|
const transport = new SSEServerTransport('/messages', res);
|
||||||
await mcpServer.connect(transport);
|
await connection.connect(transport);
|
||||||
|
|
||||||
// ...
|
// ...
|
||||||
});
|
});
|
||||||
```
|
```
|
||||||
|
</details>
|
||||||
|
|
||||||
### Tool modes
|
### Tools
|
||||||
|
|
||||||
The tools are available in two modes:
|
The tools are available in two modes:
|
||||||
|
|
||||||
@@ -255,46 +387,55 @@ 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.
|
||||||
|
|
||||||
|
<!--- Tools generated by update-readme.js -->
|
||||||
|
|
||||||
<!--- Generated by update-readme.js -->
|
<details>
|
||||||
|
<summary><b>Interactions</b></summary>
|
||||||
### Snapshot-based Interactions
|
|
||||||
|
|
||||||
<!-- NOTE: This has been generated via update-readme.js -->
|
<!-- NOTE: This has been generated via update-readme.js -->
|
||||||
|
|
||||||
- **browser_snapshot**
|
- **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
|
||||||
- Parameters: None
|
- Parameters: None
|
||||||
|
- Read-only: **true**
|
||||||
|
|
||||||
<!-- NOTE: This has been generated via update-readme.js -->
|
<!-- NOTE: This has been generated via update-readme.js -->
|
||||||
|
|
||||||
- **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
|
||||||
|
- Read-only: **false**
|
||||||
|
|
||||||
<!-- NOTE: This has been generated via update-readme.js -->
|
<!-- NOTE: This has been generated via update-readme.js -->
|
||||||
|
|
||||||
- **browser_drag**
|
- **browser_drag**
|
||||||
|
- Title: Drag mouse
|
||||||
- Description: Perform drag and drop between two elements
|
- Description: Perform drag and drop between two elements
|
||||||
- Parameters:
|
- Parameters:
|
||||||
- `startElement` (string): Human-readable source element description used to obtain the permission to interact with the element
|
- `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
|
- `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
|
- `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
|
- `endRef` (string): Exact target element reference from the page snapshot
|
||||||
|
- Read-only: **false**
|
||||||
|
|
||||||
<!-- NOTE: This has been generated via update-readme.js -->
|
<!-- 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**
|
||||||
|
|
||||||
<!-- NOTE: This has been generated via update-readme.js -->
|
<!-- NOTE: This has been generated via update-readme.js -->
|
||||||
|
|
||||||
- **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
|
||||||
@@ -302,54 +443,256 @@ X Y coordinate space, based on the provided screenshot.
|
|||||||
- `text` (string): Text to type into the element
|
- `text` (string): Text to type into the element
|
||||||
- `submit` (boolean, optional): Whether to submit entered text (press Enter after)
|
- `submit` (boolean, optional): Whether to submit entered text (press Enter after)
|
||||||
- `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.
|
- `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 -->
|
<!-- NOTE: This has been generated via update-readme.js -->
|
||||||
|
|
||||||
- **browser_select_option**
|
- **browser_select_option**
|
||||||
|
- Title: Select option
|
||||||
- Description: Select an option in a dropdown
|
- 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. This can be a single value or multiple values.
|
- `values` (array): Array of values to select in the dropdown. This can be a single value or multiple values.
|
||||||
|
- Read-only: **false**
|
||||||
|
|
||||||
|
<!-- NOTE: This has been generated via update-readme.js -->
|
||||||
|
|
||||||
|
- **browser_press_key**
|
||||||
|
- Title: Press a key
|
||||||
|
- Description: Press a key on the keyboard
|
||||||
|
- Parameters:
|
||||||
|
- `key` (string): Name of the key to press or a character to generate, such as `ArrowLeft` or `a`
|
||||||
|
- Read-only: **false**
|
||||||
|
|
||||||
|
<!-- NOTE: This has been generated via update-readme.js -->
|
||||||
|
|
||||||
|
- **browser_wait_for**
|
||||||
|
- Title: Wait for
|
||||||
|
- Description: Wait for text to appear or disappear or a specified time to pass
|
||||||
|
- Parameters:
|
||||||
|
- `time` (number, optional): The time to wait in seconds
|
||||||
|
- `text` (string, optional): The text to wait for
|
||||||
|
- `textGone` (string, optional): The text to wait for to disappear
|
||||||
|
- Read-only: **true**
|
||||||
|
|
||||||
|
<!-- NOTE: This has been generated via update-readme.js -->
|
||||||
|
|
||||||
|
- **browser_file_upload**
|
||||||
|
- Title: Upload files
|
||||||
|
- Description: Upload one or multiple files
|
||||||
|
- Parameters:
|
||||||
|
- `paths` (array): The absolute paths to the files to upload. Can be a single file or multiple files.
|
||||||
|
- Read-only: **false**
|
||||||
|
|
||||||
|
<!-- NOTE: This has been generated via update-readme.js -->
|
||||||
|
|
||||||
|
- **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**
|
||||||
|
|
||||||
|
</details>
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary><b>Navigation</b></summary>
|
||||||
|
|
||||||
|
<!-- NOTE: This has been generated via update-readme.js -->
|
||||||
|
|
||||||
|
- **browser_navigate**
|
||||||
|
- Title: Navigate to a URL
|
||||||
|
- Description: Navigate to a URL
|
||||||
|
- Parameters:
|
||||||
|
- `url` (string): The URL to navigate to
|
||||||
|
- Read-only: **false**
|
||||||
|
|
||||||
|
<!-- NOTE: This has been generated via update-readme.js -->
|
||||||
|
|
||||||
|
- **browser_navigate_back**
|
||||||
|
- Title: Go back
|
||||||
|
- Description: Go back to the previous page
|
||||||
|
- Parameters: None
|
||||||
|
- Read-only: **true**
|
||||||
|
|
||||||
|
<!-- NOTE: This has been generated via update-readme.js -->
|
||||||
|
|
||||||
|
- **browser_navigate_forward**
|
||||||
|
- Title: Go forward
|
||||||
|
- Description: Go forward to the next page
|
||||||
|
- Parameters: None
|
||||||
|
- Read-only: **true**
|
||||||
|
|
||||||
|
</details>
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary><b>Resources</b></summary>
|
||||||
|
|
||||||
<!-- NOTE: This has been generated via update-readme.js -->
|
<!-- NOTE: This has been generated via update-readme.js -->
|
||||||
|
|
||||||
- **browser_take_screenshot**
|
- **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.
|
- 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.
|
- `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.
|
- `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.
|
- `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**
|
||||||
|
|
||||||
### Vision-based Interactions
|
<!-- 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 -->
|
<!-- NOTE: This has been generated via update-readme.js -->
|
||||||
|
|
||||||
- **browser_screen_capture**
|
- **browser_screen_capture**
|
||||||
|
- Title: Take a screenshot
|
||||||
- Description: Take a screenshot of the current page
|
- Description: Take a screenshot of the current page
|
||||||
- Parameters: None
|
- Parameters: None
|
||||||
|
- Read-only: **true**
|
||||||
|
|
||||||
<!-- NOTE: This has been generated via update-readme.js -->
|
<!-- NOTE: This has been generated via update-readme.js -->
|
||||||
|
|
||||||
- **browser_screen_move_mouse**
|
- **browser_screen_move_mouse**
|
||||||
|
- Title: Move mouse
|
||||||
- Description: Move mouse to a given position
|
- Description: Move mouse to a given position
|
||||||
- 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
|
||||||
- `x` (number): X coordinate
|
- `x` (number): X coordinate
|
||||||
- `y` (number): Y coordinate
|
- `y` (number): Y coordinate
|
||||||
|
- Read-only: **true**
|
||||||
|
|
||||||
<!-- NOTE: This has been generated via update-readme.js -->
|
<!-- NOTE: This has been generated via update-readme.js -->
|
||||||
|
|
||||||
- **browser_screen_click**
|
- **browser_screen_click**
|
||||||
|
- Title: Click
|
||||||
- Description: Click left mouse button
|
- Description: Click left mouse button
|
||||||
- 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
|
||||||
- `x` (number): X coordinate
|
- `x` (number): X coordinate
|
||||||
- `y` (number): Y coordinate
|
- `y` (number): Y coordinate
|
||||||
|
- Read-only: **false**
|
||||||
|
|
||||||
<!-- NOTE: This has been generated via update-readme.js -->
|
<!-- NOTE: This has been generated via update-readme.js -->
|
||||||
|
|
||||||
- **browser_screen_drag**
|
- **browser_screen_drag**
|
||||||
|
- Title: Drag mouse
|
||||||
- Description: Drag left mouse button
|
- Description: Drag left mouse button
|
||||||
- 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
|
||||||
@@ -357,132 +700,58 @@ X Y coordinate space, based on the provided screenshot.
|
|||||||
- `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**
|
||||||
|
|
||||||
<!-- NOTE: This has been generated via update-readme.js -->
|
<!-- NOTE: This has been generated via update-readme.js -->
|
||||||
|
|
||||||
- **browser_screen_type**
|
- **browser_screen_type**
|
||||||
|
- Title: Type text
|
||||||
- Description: Type text
|
- Description: Type text
|
||||||
- Parameters:
|
- Parameters:
|
||||||
- `text` (string): Text to type into the element
|
- `text` (string): Text to type into the element
|
||||||
- `submit` (boolean, optional): Whether to submit entered text (press Enter after)
|
- `submit` (boolean, optional): Whether to submit entered text (press Enter after)
|
||||||
|
- Read-only: **false**
|
||||||
### Tab Management
|
|
||||||
|
|
||||||
<!-- NOTE: This has been generated via update-readme.js -->
|
|
||||||
|
|
||||||
- **browser_tab_list**
|
|
||||||
- Description: List browser tabs
|
|
||||||
- Parameters: None
|
|
||||||
|
|
||||||
<!-- NOTE: This has been generated via update-readme.js -->
|
|
||||||
|
|
||||||
- **browser_tab_new**
|
|
||||||
- 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.
|
|
||||||
|
|
||||||
<!-- NOTE: This has been generated via update-readme.js -->
|
|
||||||
|
|
||||||
- **browser_tab_select**
|
|
||||||
- Description: Select a tab by index
|
|
||||||
- Parameters:
|
|
||||||
- `index` (number): The index of the tab to select
|
|
||||||
|
|
||||||
<!-- NOTE: This has been generated via update-readme.js -->
|
|
||||||
|
|
||||||
- **browser_tab_close**
|
|
||||||
- Description: Close a tab
|
|
||||||
- Parameters:
|
|
||||||
- `index` (number, optional): The index of the tab to close. Closes current tab if not provided.
|
|
||||||
|
|
||||||
### Navigation
|
|
||||||
|
|
||||||
<!-- NOTE: This has been generated via update-readme.js -->
|
|
||||||
|
|
||||||
- **browser_navigate**
|
|
||||||
- Description: Navigate to a URL
|
|
||||||
- Parameters:
|
|
||||||
- `url` (string): The URL to navigate to
|
|
||||||
|
|
||||||
<!-- NOTE: This has been generated via update-readme.js -->
|
|
||||||
|
|
||||||
- **browser_navigate_back**
|
|
||||||
- Description: Go back to the previous page
|
|
||||||
- Parameters: None
|
|
||||||
|
|
||||||
<!-- NOTE: This has been generated via update-readme.js -->
|
|
||||||
|
|
||||||
- **browser_navigate_forward**
|
|
||||||
- Description: Go forward to the next page
|
|
||||||
- Parameters: None
|
|
||||||
|
|
||||||
### Keyboard
|
|
||||||
|
|
||||||
<!-- NOTE: This has been generated via update-readme.js -->
|
<!-- 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**
|
||||||
### Console
|
|
||||||
|
|
||||||
<!-- NOTE: This has been generated via update-readme.js -->
|
<!-- NOTE: This has been generated via update-readme.js -->
|
||||||
|
|
||||||
- **browser_console_messages**
|
- **browser_wait_for**
|
||||||
- Description: Returns all console messages
|
- Title: Wait for
|
||||||
- Parameters: None
|
- Description: Wait for text to appear or disappear or a specified time to pass
|
||||||
|
- Parameters:
|
||||||
### Files and Media
|
- `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 -->
|
<!-- NOTE: This has been generated via update-readme.js -->
|
||||||
|
|
||||||
- **browser_file_upload**
|
- **browser_file_upload**
|
||||||
|
- Title: Upload files
|
||||||
- Description: Upload one or multiple 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**
|
||||||
<!-- NOTE: This has been generated via update-readme.js -->
|
|
||||||
|
|
||||||
- **browser_pdf_save**
|
|
||||||
- Description: Save page as PDF
|
|
||||||
- Parameters: None
|
|
||||||
|
|
||||||
### Utilities
|
|
||||||
|
|
||||||
<!-- NOTE: This has been generated via update-readme.js -->
|
|
||||||
|
|
||||||
- **browser_close**
|
|
||||||
- Description: Close the page
|
|
||||||
- Parameters: None
|
|
||||||
|
|
||||||
<!-- NOTE: This has been generated via update-readme.js -->
|
|
||||||
|
|
||||||
- **browser_wait**
|
|
||||||
- Description: Wait for a specified time in seconds
|
|
||||||
- Parameters:
|
|
||||||
- `time` (number): The time to wait in seconds
|
|
||||||
|
|
||||||
<!-- NOTE: This has been generated via update-readme.js -->
|
|
||||||
|
|
||||||
- **browser_resize**
|
|
||||||
- Description: Resize the browser window
|
|
||||||
- Parameters:
|
|
||||||
- `width` (number): Width of the browser window
|
|
||||||
- `height` (number): Height of the browser window
|
|
||||||
|
|
||||||
<!-- NOTE: This has been generated via update-readme.js -->
|
|
||||||
|
|
||||||
- **browser_install**
|
|
||||||
- Description: Install the browser specified in the config. Call this if you get an error about the browser not being installed.
|
|
||||||
- Parameters: None
|
|
||||||
|
|
||||||
<!-- NOTE: This has been generated via update-readme.js -->
|
<!-- NOTE: This has been generated via update-readme.js -->
|
||||||
|
|
||||||
- **browser_handle_dialog**
|
- **browser_handle_dialog**
|
||||||
|
- Title: Handle a dialog
|
||||||
- Description: Handle a dialog
|
- Description: Handle a dialog
|
||||||
- Parameters:
|
- Parameters:
|
||||||
- `accept` (boolean): Whether to accept the dialog.
|
- `accept` (boolean): Whether to accept the dialog.
|
||||||
- `promptText` (string, optional): The text of the prompt in case of a prompt dialog.
|
- `promptText` (string, optional): The text of the prompt in case of a prompt dialog.
|
||||||
|
- Read-only: **false**
|
||||||
|
|
||||||
<!--- End of generated section -->
|
</details>
|
||||||
|
|
||||||
|
|
||||||
|
<!--- 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';
|
||||||
|
|||||||
40
config.d.ts
vendored
40
config.d.ts
vendored
@@ -16,7 +16,7 @@
|
|||||||
|
|
||||||
import type * as playwright from 'playwright';
|
import type * as playwright from 'playwright';
|
||||||
|
|
||||||
export type ToolCapability = 'core' | 'tabs' | 'pdf' | 'history' | 'wait' | 'files' | 'install';
|
export type ToolCapability = 'core' | 'tabs' | 'pdf' | 'history' | 'wait' | 'files' | 'install' | 'testing';
|
||||||
|
|
||||||
export type Config = {
|
export type Config = {
|
||||||
/**
|
/**
|
||||||
@@ -28,6 +28,11 @@ export type Config = {
|
|||||||
*/
|
*/
|
||||||
browserName?: 'chromium' | 'firefox' | 'webkit';
|
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.
|
* Path to a user data directory for browser profile persistence.
|
||||||
* Temporary directory is created by default.
|
* Temporary directory is created by default.
|
||||||
@@ -40,7 +45,7 @@ export type Config = {
|
|||||||
*
|
*
|
||||||
* This is useful for settings options like `channel`, `headless`, `executablePath`, etc.
|
* This is useful for settings options like `channel`, `headless`, `executablePath`, etc.
|
||||||
*/
|
*/
|
||||||
launchOptions?: playwright.BrowserLaunchOptions;
|
launchOptions?: playwright.LaunchOptions;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Context options for the browser context.
|
* Context options for the browser context.
|
||||||
@@ -89,25 +94,30 @@ export type Config = {
|
|||||||
*/
|
*/
|
||||||
vision?: boolean;
|
vision?: boolean;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether to save the Playwright trace of the session into the output directory.
|
||||||
|
*/
|
||||||
|
saveTrace?: boolean;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The directory to save output files.
|
* The directory to save output files.
|
||||||
*/
|
*/
|
||||||
outputDir?: string;
|
outputDir?: string;
|
||||||
|
|
||||||
/**
|
network?: {
|
||||||
* Configuration for specific tools.
|
|
||||||
*/
|
|
||||||
tools?: {
|
|
||||||
/**
|
/**
|
||||||
* Configuration for the browser_take_screenshot tool.
|
* List of origins to allow the browser to request. Default is to allow all. Origins matching both `allowedOrigins` and `blockedOrigins` will be blocked.
|
||||||
*/
|
*/
|
||||||
browser_take_screenshot?: {
|
allowedOrigins?: string[];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Whether to disable base64-encoded image responses to the clients that
|
* List of origins to block the browser to request. Origins matching both `allowedOrigins` and `blockedOrigins` will be blocked.
|
||||||
* don't support binary data or prefer to save on tokens.
|
*/
|
||||||
*/
|
blockedOrigins?: string[];
|
||||||
omitBase64?: boolean;
|
};
|
||||||
}
|
|
||||||
}
|
/**
|
||||||
|
* 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,7 @@ const plugins = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const baseRules = {
|
export const baseRules = {
|
||||||
|
"import/extensions": ["error", "ignorePackages", {ts: "always"}],
|
||||||
"@typescript-eslint/no-floating-promises": "error",
|
"@typescript-eslint/no-floating-promises": "error",
|
||||||
"@typescript-eslint/no-unused-vars": [
|
"@typescript-eslint/no-unused-vars": [
|
||||||
2,
|
2,
|
||||||
@@ -179,6 +180,7 @@ export const baseRules = {
|
|||||||
|
|
||||||
// react
|
// react
|
||||||
"react/react-in-jsx-scope": 0,
|
"react/react-in-jsx-scope": 0,
|
||||||
|
"no-console": 2,
|
||||||
};
|
};
|
||||||
|
|
||||||
const languageOptions = {
|
const languageOptions = {
|
||||||
|
|||||||
47
examples.md
47
examples.md
@@ -1,47 +0,0 @@
|
|||||||
# Examples
|
|
||||||
|
|
||||||
Below are some examples of prompts to use with Playwright MCP. Draw inspiration from them to create your own. If you come up with a new prompt that works well, feel free to let us know in an issue or pull request.
|
|
||||||
|
|
||||||
## Write a regression test for a bug report
|
|
||||||
|
|
||||||
We have received a user report about a bug on our website https://debs-obrien.github.io/playwright-movies-app. I'd like you to verify if this bug exists by trying to follow the steps. If you see different behaviour, explain how it's different. If you see the same behaviour, please write me a Playwright regression test that encapsulates the buggy user flow. While writing the test, draw inspiration from the Playwright code you ran. Here's the user report:
|
|
||||||
|
|
||||||
Howdy. I love your movies site, but there's something fishy going on. I'm looking at The Garfield Movie - a spectacular film - but somehow it's showing the wrong length. The movie is 90mins long, I measured myself yesterday! But somehow your site is claiming 101mins. Am I looking at it wrong, or is there a bug?
|
|
||||||
|
|
||||||
**Output:**
|
|
||||||
|
|
||||||
```ts
|
|
||||||
import { test, expect } from '@playwright/test';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Regression test for bug: The Garfield Movie showing incorrect runtime
|
|
||||||
* User reported actual runtime is 90 minutes, but site shows 101 minutes
|
|
||||||
*/
|
|
||||||
test('verify The Garfield Movie runtime bug', async ({ page }) => {
|
|
||||||
// Navigate to the movies app home page
|
|
||||||
await page.goto('https://debs-obrien.github.io/playwright-movies-app');
|
|
||||||
|
|
||||||
// Wait for page to load completely
|
|
||||||
await expect(page.getByRole('heading', { name: 'Popular', level: 1 })).toBeVisible();
|
|
||||||
|
|
||||||
// Look for The Garfield Movie and click on it
|
|
||||||
const garfieldMovieLink = page.getByRole('link', { name: /poster of The Garfield Movie/i });
|
|
||||||
await expect(garfieldMovieLink).toBeVisible();
|
|
||||||
await garfieldMovieLink.click();
|
|
||||||
|
|
||||||
// Verify we're on the correct movie page
|
|
||||||
await expect(page.getByRole('heading', { name: 'The Garfield Movie', level: 1 })).toBeVisible();
|
|
||||||
|
|
||||||
// Check the runtime information
|
|
||||||
// The bug: Expected runtime should be 90 min, but site shows 101 min
|
|
||||||
const movieInfoText = await page.locator('text=English / 101 min. / 2024').textContent();
|
|
||||||
|
|
||||||
// This test will fail because of the bug (which is what we want to demonstrate)
|
|
||||||
// Once fixed, this assertion should be updated to the correct runtime (90 min)
|
|
||||||
expect(movieInfoText).toContain('90 min');
|
|
||||||
|
|
||||||
// Alternative assertion that verifies the incorrect runtime is still present
|
|
||||||
// Uncomment this and comment the above assertion to verify the bug exists
|
|
||||||
// expect(movieInfoText).toContain('101 min');
|
|
||||||
});
|
|
||||||
```
|
|
||||||
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.
|
||||||
10
index.d.ts
vendored
10
index.d.ts
vendored
@@ -16,8 +16,14 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import type { Server } from '@modelcontextprotocol/sdk/server/index.js';
|
import type { Server } from '@modelcontextprotocol/sdk/server/index.js';
|
||||||
|
|
||||||
import type { Config } from './config';
|
import type { Config } from './config';
|
||||||
|
import type { Transport } from '@modelcontextprotocol/sdk/shared/transport.js';
|
||||||
|
|
||||||
export declare function createServer(config?: Config): Promise<Server>;
|
export type Connection = {
|
||||||
|
server: Server;
|
||||||
|
connect(transport: Transport): Promise<void>;
|
||||||
|
close(): Promise<void>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export declare function createConnection(config?: Config): Promise<Connection>;
|
||||||
export {};
|
export {};
|
||||||
|
|||||||
4
index.js
4
index.js
@@ -15,5 +15,5 @@
|
|||||||
* limitations under the License.
|
* limitations under the License.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
const { createServer } = require('./lib/index');
|
import { createConnection } from './lib/index.js';
|
||||||
module.exports = { createServer };
|
export { createConnection };
|
||||||
|
|||||||
51
package-lock.json
generated
51
package-lock.json
generated
@@ -1,18 +1,17 @@
|
|||||||
{
|
{
|
||||||
"name": "@playwright/mcp",
|
"name": "@playwright/mcp",
|
||||||
"version": "0.0.17",
|
"version": "0.0.27",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "@playwright/mcp",
|
"name": "@playwright/mcp",
|
||||||
"version": "0.0.17",
|
"version": "0.0.27",
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@modelcontextprotocol/sdk": "^1.10.1",
|
"@modelcontextprotocol/sdk": "^1.11.0",
|
||||||
"commander": "^13.1.0",
|
"commander": "^13.1.0",
|
||||||
"playwright": "1.53.0-alpha-2025-04-25",
|
"playwright": "1.53.0-alpha-2025-05-27",
|
||||||
"yaml": "^2.7.1",
|
|
||||||
"zod-to-json-schema": "^3.24.4"
|
"zod-to-json-schema": "^3.24.4"
|
||||||
},
|
},
|
||||||
"bin": {
|
"bin": {
|
||||||
@@ -21,7 +20,7 @@
|
|||||||
"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.53.0-alpha-2025-04-25",
|
"@playwright/test": "1.53.0-alpha-2025-05-27",
|
||||||
"@stylistic/eslint-plugin": "^3.0.1",
|
"@stylistic/eslint-plugin": "^3.0.1",
|
||||||
"@types/node": "^22.13.10",
|
"@types/node": "^22.13.10",
|
||||||
"@typescript-eslint/eslint-plugin": "^8.26.1",
|
"@typescript-eslint/eslint-plugin": "^8.26.1",
|
||||||
@@ -228,9 +227,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@modelcontextprotocol/sdk": {
|
"node_modules/@modelcontextprotocol/sdk": {
|
||||||
"version": "1.10.1",
|
"version": "1.11.0",
|
||||||
"resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.10.1.tgz",
|
"resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.11.0.tgz",
|
||||||
"integrity": "sha512-xNYdFdkJqEfIaTVP1gPKoEvluACHZsHZegIoICX8DM1o6Qf3G5u2BQJHmgd0n4YgRPqqK/u1ujQvrgAxxSJT9w==",
|
"integrity": "sha512-k/1pb70eD638anoi0e8wUGAlbMJXyvdV4p62Ko+EZ7eBe1xMx8Uhak1R5DgfoofsK5IBBnRwsYGTaLZl+6/+RQ==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"content-type": "^1.0.5",
|
"content-type": "^1.0.5",
|
||||||
@@ -287,13 +286,13 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@playwright/test": {
|
"node_modules/@playwright/test": {
|
||||||
"version": "1.53.0-alpha-2025-04-25",
|
"version": "1.53.0-alpha-2025-05-27",
|
||||||
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.53.0-alpha-2025-04-25.tgz",
|
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.53.0-alpha-2025-05-27.tgz",
|
||||||
"integrity": "sha512-3y4C2ZjAc2oUpwavC2yG2JzH53TOKgcMZvWb5GmpxnOa6fhuSVXK0kIsiIaImKmdffIVM1agsqNHp8yldeBTHQ==",
|
"integrity": "sha512-G2zG56kEQOWhk3nQyPKH5u41jyQw5jx+Kga5huUi7RjBjPEnNtiCMNXMNGCh6dDYCIyQkLJvz/o1H/QN26HLsg==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"playwright": "1.53.0-alpha-2025-04-25"
|
"playwright": "1.53.0-alpha-2025-05-27"
|
||||||
},
|
},
|
||||||
"bin": {
|
"bin": {
|
||||||
"playwright": "cli.js"
|
"playwright": "cli.js"
|
||||||
@@ -3299,12 +3298,12 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/playwright": {
|
"node_modules/playwright": {
|
||||||
"version": "1.53.0-alpha-2025-04-25",
|
"version": "1.53.0-alpha-2025-05-27",
|
||||||
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.53.0-alpha-2025-04-25.tgz",
|
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.53.0-alpha-2025-05-27.tgz",
|
||||||
"integrity": "sha512-b5VT4lWgyhhy99zHeCoUBt/FQckPxeQVA5ksvxBv0HeqcEvzZzhuyqrrcZewJyflE+5U+bmvqI+yoU0ks8mE3Q==",
|
"integrity": "sha512-CD0BTwV5javEJ3hf3rhFJEvR3ZoWsu4HUQFfLH2mtVVe+grGPCP55FnlOjpDnJ5pP4Kibe/ZcmgPDg56ic/y9g==",
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"playwright-core": "1.53.0-alpha-2025-04-25"
|
"playwright-core": "1.53.0-alpha-2025-05-27"
|
||||||
},
|
},
|
||||||
"bin": {
|
"bin": {
|
||||||
"playwright": "cli.js"
|
"playwright": "cli.js"
|
||||||
@@ -3317,9 +3316,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/playwright-core": {
|
"node_modules/playwright-core": {
|
||||||
"version": "1.53.0-alpha-2025-04-25",
|
"version": "1.53.0-alpha-2025-05-27",
|
||||||
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.53.0-alpha-2025-04-25.tgz",
|
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.53.0-alpha-2025-05-27.tgz",
|
||||||
"integrity": "sha512-gjV01l6A4q/zg+/pwEX50k9lhYWaE9NcDVypSDD331jB3EYrdk0LeDQxqz5XFDOzq/tC/8QTouDs9a/s/p95hA==",
|
"integrity": "sha512-uVxs7YjENoBMFyQhsZWImIBuo/oX7Mu63djhQN3qFz/NdXA/rOAnP73XzfB+VJNwRMKgIOtqHQgjOG3Rl/lm0A==",
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"bin": {
|
"bin": {
|
||||||
"playwright-core": "cli.js"
|
"playwright-core": "cli.js"
|
||||||
@@ -4350,18 +4349,6 @@
|
|||||||
"integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==",
|
"integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==",
|
||||||
"license": "ISC"
|
"license": "ISC"
|
||||||
},
|
},
|
||||||
"node_modules/yaml": {
|
|
||||||
"version": "2.7.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/yaml/-/yaml-2.7.1.tgz",
|
|
||||||
"integrity": "sha512-10ULxpnOCQXxJvBgxsn9ptjq6uviG/htZKk9veJGhlqn3w/DxQ631zFF+nlQXLwmImeS5amR2dl2U8sg6U9jsQ==",
|
|
||||||
"license": "ISC",
|
|
||||||
"bin": {
|
|
||||||
"yaml": "bin.mjs"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">= 14"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/yocto-queue": {
|
"node_modules/yocto-queue": {
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz",
|
||||||
|
|||||||
12
package.json
12
package.json
@@ -1,7 +1,8 @@
|
|||||||
{
|
{
|
||||||
"name": "@playwright/mcp",
|
"name": "@playwright/mcp",
|
||||||
"version": "0.0.17",
|
"version": "0.0.27",
|
||||||
"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,7 +17,7 @@
|
|||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "tsc",
|
"build": "tsc",
|
||||||
"lint": "npm run update-readme && eslint .",
|
"lint": "npm run update-readme && eslint . && tsc --noEmit",
|
||||||
"update-readme": "node utils/update-readme.js",
|
"update-readme": "node utils/update-readme.js",
|
||||||
"watch": "tsc --watch",
|
"watch": "tsc --watch",
|
||||||
"test": "playwright test",
|
"test": "playwright test",
|
||||||
@@ -34,16 +35,15 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@modelcontextprotocol/sdk": "^1.10.1",
|
"@modelcontextprotocol/sdk": "^1.11.0",
|
||||||
"commander": "^13.1.0",
|
"commander": "^13.1.0",
|
||||||
"playwright": "1.53.0-alpha-2025-04-25",
|
"playwright": "1.53.0-alpha-2025-05-27",
|
||||||
"yaml": "^2.7.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.53.0-alpha-2025-04-25",
|
"@playwright/test": "1.53.0-alpha-2025-05-27",
|
||||||
"@stylistic/eslint-plugin": "^3.0.1",
|
"@stylistic/eslint-plugin": "^3.0.1",
|
||||||
"@types/node": "^22.13.10",
|
"@types/node": "^22.13.10",
|
||||||
"@typescript-eslint/eslint-plugin": "^8.26.1",
|
"@typescript-eslint/eslint-plugin": "^8.26.1",
|
||||||
|
|||||||
@@ -16,9 +16,9 @@
|
|||||||
|
|
||||||
import { defineConfig } from '@playwright/test';
|
import { defineConfig } from '@playwright/test';
|
||||||
|
|
||||||
import type { Project } from '@playwright/test';
|
import type { TestOptions } from './tests/fixtures.js';
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig<TestOptions>({
|
||||||
testDir: './tests',
|
testDir: './tests',
|
||||||
fullyParallel: true,
|
fullyParallel: true,
|
||||||
forbidOnly: !!process.env.CI,
|
forbidOnly: !!process.env.CI,
|
||||||
@@ -29,7 +29,15 @@ export default defineConfig({
|
|||||||
{ name: 'chrome' },
|
{ name: 'chrome' },
|
||||||
{ name: 'msedge', use: { mcpBrowser: 'msedge' } },
|
{ name: 'msedge', use: { mcpBrowser: 'msedge' } },
|
||||||
{ name: 'chromium', use: { mcpBrowser: 'chromium' } },
|
{ 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: 'firefox', use: { mcpBrowser: 'firefox' } },
|
||||||
{ name: 'webkit', use: { mcpBrowser: 'webkit' } },
|
{ name: 'webkit', use: { mcpBrowser: 'webkit' } },
|
||||||
].filter(Boolean) as Project[],
|
],
|
||||||
});
|
});
|
||||||
|
|||||||
184
src/config.ts
184
src/config.ts
@@ -20,47 +20,87 @@ import os from 'os';
|
|||||||
import path from 'path';
|
import path from 'path';
|
||||||
import { devices } from 'playwright';
|
import { devices } from 'playwright';
|
||||||
|
|
||||||
import { sanitizeForFilePath } from './tools/utils';
|
import type { Config, ToolCapability } from '../config.js';
|
||||||
|
|
||||||
import type { Config, ToolCapability } from '../config';
|
|
||||||
import type { BrowserContextOptions, LaunchOptions } from 'playwright';
|
import type { BrowserContextOptions, LaunchOptions } from 'playwright';
|
||||||
|
import { sanitizeForFilePath } from './tools/utils.js';
|
||||||
|
|
||||||
export type CLIOptions = {
|
export type CLIOptions = {
|
||||||
|
allowedOrigins?: string[];
|
||||||
|
blockedOrigins?: string[];
|
||||||
|
blockServiceWorkers?: boolean;
|
||||||
browser?: string;
|
browser?: string;
|
||||||
caps?: string;
|
caps?: string;
|
||||||
cdpEndpoint?: string;
|
cdpEndpoint?: string;
|
||||||
|
config?: string;
|
||||||
|
device?: string;
|
||||||
executablePath?: string;
|
executablePath?: string;
|
||||||
headless?: boolean;
|
headless?: boolean;
|
||||||
device?: string;
|
|
||||||
userDataDir?: string;
|
|
||||||
port?: number;
|
|
||||||
host?: string;
|
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;
|
vision?: boolean;
|
||||||
config?: string;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const defaultConfig: Config = {
|
const defaultConfig: FullConfig = {
|
||||||
browser: {
|
browser: {
|
||||||
browserName: 'chromium',
|
browserName: 'chromium',
|
||||||
userDataDir: os.tmpdir(),
|
|
||||||
launchOptions: {
|
launchOptions: {
|
||||||
channel: 'chrome',
|
channel: 'chrome',
|
||||||
headless: os.platform() === 'linux' && !process.env.DISPLAY,
|
headless: os.platform() === 'linux' && !process.env.DISPLAY,
|
||||||
|
chromiumSandbox: true,
|
||||||
},
|
},
|
||||||
contextOptions: {
|
contextOptions: {
|
||||||
viewport: null,
|
viewport: null,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
network: {
|
||||||
|
allowedOrigins: undefined,
|
||||||
|
blockedOrigins: undefined,
|
||||||
|
},
|
||||||
|
outputDir: path.join(os.tmpdir(), 'playwright-mcp-output', sanitizeForFilePath(new Date().toISOString())),
|
||||||
};
|
};
|
||||||
|
|
||||||
export async function resolveConfig(cliOptions: CLIOptions): Promise<Config> {
|
type BrowserUserConfig = NonNullable<Config['browser']>;
|
||||||
const config = await loadConfig(cliOptions.config);
|
|
||||||
|
export type FullConfig = Config & {
|
||||||
|
browser: BrowserUserConfig & {
|
||||||
|
browserName: NonNullable<BrowserUserConfig['browserName']>;
|
||||||
|
launchOptions: NonNullable<BrowserUserConfig['launchOptions']>;
|
||||||
|
contextOptions: NonNullable<BrowserUserConfig['contextOptions']>;
|
||||||
|
},
|
||||||
|
network: NonNullable<Config['network']>,
|
||||||
|
outputDir: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
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 cliOverrides = await configFromCLIOptions(cliOptions);
|
||||||
return mergeConfig(defaultConfig, mergeConfig(config, cliOverrides));
|
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');
|
||||||
|
if (result.browser.browserName === 'chromium')
|
||||||
|
(result.browser.launchOptions as any).cdpPort = await findFreePort();
|
||||||
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function configFromCLIOptions(cliOptions: CLIOptions): Promise<Config> {
|
export async function configFromCLIOptions(cliOptions: CLIOptions): Promise<Config> {
|
||||||
let browserName: 'chromium' | 'firefox' | 'webkit';
|
let browserName: 'chromium' | 'firefox' | 'webkit' | undefined;
|
||||||
let channel: string | undefined;
|
let channel: string | undefined;
|
||||||
switch (cliOptions.browser) {
|
switch (cliOptions.browser) {
|
||||||
case 'chrome':
|
case 'chrome':
|
||||||
@@ -81,26 +121,57 @@ export async function configFromCLIOptions(cliOptions: CLIOptions): Promise<Conf
|
|||||||
case 'webkit':
|
case 'webkit':
|
||||||
browserName = 'webkit';
|
browserName = 'webkit';
|
||||||
break;
|
break;
|
||||||
default:
|
|
||||||
browserName = 'chromium';
|
|
||||||
channel = 'chrome';
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Launch options
|
||||||
const launchOptions: LaunchOptions = {
|
const launchOptions: LaunchOptions = {
|
||||||
channel,
|
channel,
|
||||||
executablePath: cliOptions.executablePath,
|
executablePath: cliOptions.executablePath,
|
||||||
headless: cliOptions.headless ?? false,
|
headless: cliOptions.headless,
|
||||||
};
|
};
|
||||||
|
|
||||||
if (browserName === 'chromium')
|
// --no-sandbox was passed, disable the sandbox
|
||||||
(launchOptions as any).webSocketPort = await findFreePort();
|
if (!cliOptions.sandbox)
|
||||||
|
launchOptions.chromiumSandbox = false;
|
||||||
|
|
||||||
const contextOptions: BrowserContextOptions | undefined = cliOptions.device ? devices[cliOptions.device] : undefined;
|
if (cliOptions.proxyServer) {
|
||||||
|
launchOptions.proxy = {
|
||||||
|
server: cliOptions.proxyServer
|
||||||
|
};
|
||||||
|
if (cliOptions.proxyBypass)
|
||||||
|
launchOptions.proxy.bypass = cliOptions.proxyBypass;
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
// 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: {
|
browser: {
|
||||||
browserName,
|
browserName,
|
||||||
userDataDir: cliOptions.userDataDir ?? await createUserDataDir({ browserName, channel }),
|
isolated: cliOptions.isolated,
|
||||||
|
userDataDir: cliOptions.userDataDir,
|
||||||
launchOptions,
|
launchOptions,
|
||||||
contextOptions,
|
contextOptions,
|
||||||
cdpEndpoint: cliOptions.cdpEndpoint,
|
cdpEndpoint: cliOptions.cdpEndpoint,
|
||||||
@@ -111,7 +182,16 @@ export async function configFromCLIOptions(cliOptions: CLIOptions): Promise<Conf
|
|||||||
},
|
},
|
||||||
capabilities: cliOptions.caps?.split(',').map((c: string) => c.trim() as ToolCapability),
|
capabilities: cliOptions.caps?.split(',').map((c: string) => c.trim() as ToolCapability),
|
||||||
vision: !!cliOptions.vision,
|
vision: !!cliOptions.vision,
|
||||||
|
network: {
|
||||||
|
allowedOrigins: cliOptions.allowedOrigins,
|
||||||
|
blockedOrigins: cliOptions.blockedOrigins,
|
||||||
|
},
|
||||||
|
saveTrace: cliOptions.saveTrace,
|
||||||
|
outputDir: cliOptions.outputDir,
|
||||||
|
imageResponses: cliOptions.imageResponses,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function findFreePort() {
|
async function findFreePort() {
|
||||||
@@ -136,46 +216,46 @@ async function loadConfig(configFile: string | undefined): Promise<Config> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function createUserDataDir(options: { browserName: 'chromium' | 'firefox' | 'webkit', channel: string | undefined }) {
|
export async function outputFile(config: FullConfig, name: string): Promise<string> {
|
||||||
let cacheDirectory: string;
|
await fs.promises.mkdir(config.outputDir, { recursive: true });
|
||||||
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-${options.channel ?? options.browserName}-profile`);
|
|
||||||
await fs.promises.mkdir(result, { recursive: true });
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function outputFile(config: Config, name: string): Promise<string> {
|
|
||||||
const result = config.outputDir ?? os.tmpdir();
|
|
||||||
await fs.promises.mkdir(result, { recursive: true });
|
|
||||||
const fileName = sanitizeForFilePath(name);
|
const fileName = sanitizeForFilePath(name);
|
||||||
return path.join(result, fileName);
|
return path.join(config.outputDir, fileName);
|
||||||
}
|
}
|
||||||
|
|
||||||
function mergeConfig(base: Config, overrides: Config): Config {
|
function pickDefined<T extends object>(obj: T | undefined): Partial<T> {
|
||||||
const browser: Config['browser'] = {
|
return Object.fromEntries(
|
||||||
...base.browser,
|
Object.entries(obj ?? {}).filter(([_, v]) => v !== undefined)
|
||||||
...overrides.browser,
|
) as Partial<T>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function mergeConfig(base: FullConfig, overrides: Config): FullConfig {
|
||||||
|
const browser: FullConfig['browser'] = {
|
||||||
|
browserName: overrides.browser?.browserName ?? base.browser?.browserName ?? 'chromium',
|
||||||
|
isolated: overrides.browser?.isolated ?? base.browser?.isolated ?? false,
|
||||||
launchOptions: {
|
launchOptions: {
|
||||||
...base.browser?.launchOptions,
|
...pickDefined(base.browser?.launchOptions),
|
||||||
...overrides.browser?.launchOptions,
|
...pickDefined(overrides.browser?.launchOptions),
|
||||||
...{ assistantMode: true },
|
...{ assistantMode: true },
|
||||||
},
|
},
|
||||||
contextOptions: {
|
contextOptions: {
|
||||||
...base.browser?.contextOptions,
|
...pickDefined(base.browser?.contextOptions),
|
||||||
...overrides.browser?.contextOptions,
|
...pickDefined(overrides.browser?.contextOptions),
|
||||||
},
|
},
|
||||||
|
userDataDir: overrides.browser?.userDataDir ?? base.browser?.userDataDir,
|
||||||
|
cdpEndpoint: overrides.browser?.cdpEndpoint ?? base.browser?.cdpEndpoint,
|
||||||
|
remoteEndpoint: overrides.browser?.remoteEndpoint ?? base.browser?.remoteEndpoint,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
if (browser.browserName !== 'chromium' && browser.launchOptions)
|
||||||
|
delete browser.launchOptions.channel;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...base,
|
...pickDefined(base),
|
||||||
...overrides,
|
...pickDefined(overrides),
|
||||||
browser,
|
browser,
|
||||||
};
|
network: {
|
||||||
|
...pickDefined(base.network),
|
||||||
|
...pickDefined(overrides.network),
|
||||||
|
}
|
||||||
|
} as FullConfig;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,24 +15,21 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { Server } from '@modelcontextprotocol/sdk/server/index.js';
|
import { Server } from '@modelcontextprotocol/sdk/server/index.js';
|
||||||
import { CallToolRequestSchema, ListToolsRequestSchema } from '@modelcontextprotocol/sdk/types.js';
|
import { CallToolRequestSchema, ListToolsRequestSchema, Tool as McpTool } from '@modelcontextprotocol/sdk/types.js';
|
||||||
import { zodToJsonSchema } from 'zod-to-json-schema';
|
import { zodToJsonSchema } from 'zod-to-json-schema';
|
||||||
|
|
||||||
import { Context } from './context';
|
import { Context, packageJSON } from './context.js';
|
||||||
|
import { snapshotTools, visionTools } from './tools.js';
|
||||||
|
|
||||||
import type { Tool } from './tools/tool';
|
import type { Transport } from '@modelcontextprotocol/sdk/shared/transport.js';
|
||||||
import type { Config } from '../config';
|
import { FullConfig } from './config.js';
|
||||||
|
|
||||||
type MCPServerOptions = {
|
export async function createConnection(config: FullConfig): Promise<Connection> {
|
||||||
name: string;
|
const allTools = config.vision ? visionTools : snapshotTools;
|
||||||
version: string;
|
const tools = allTools.filter(tool => !config.capabilities || tool.capability === 'core' || config.capabilities.includes(tool.capability));
|
||||||
tools: Tool[];
|
|
||||||
};
|
|
||||||
|
|
||||||
export function createServerWithTools(serverOptions: MCPServerOptions, config: Config): Server {
|
|
||||||
const { name, version, tools } = serverOptions;
|
|
||||||
const context = new Context(tools, config);
|
const context = new Context(tools, config);
|
||||||
const server = new Server({ name, version }, {
|
const server = new Server({ name: 'Playwright', version: packageJSON.version }, {
|
||||||
capabilities: {
|
capabilities: {
|
||||||
tools: {},
|
tools: {},
|
||||||
}
|
}
|
||||||
@@ -43,8 +40,14 @@ export function createServerWithTools(serverOptions: MCPServerOptions, config: C
|
|||||||
tools: tools.map(tool => ({
|
tools: tools.map(tool => ({
|
||||||
name: tool.schema.name,
|
name: tool.schema.name,
|
||||||
description: tool.schema.description,
|
description: tool.schema.description,
|
||||||
inputSchema: zodToJsonSchema(tool.schema.inputSchema)
|
inputSchema: zodToJsonSchema(tool.schema.inputSchema),
|
||||||
})),
|
annotations: {
|
||||||
|
title: tool.schema.title,
|
||||||
|
readOnlyHint: tool.schema.type === 'readOnly',
|
||||||
|
destructiveHint: tool.schema.type === 'destructive',
|
||||||
|
openWorldHint: true,
|
||||||
|
},
|
||||||
|
})) as McpTool[],
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -71,38 +74,29 @@ export function createServerWithTools(serverOptions: MCPServerOptions, config: C
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
const oldClose = server.close.bind(server);
|
const connection = new Connection(server, context);
|
||||||
|
return connection;
|
||||||
server.close = async () => {
|
|
||||||
await oldClose();
|
|
||||||
await context.close();
|
|
||||||
};
|
|
||||||
|
|
||||||
return server;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export class ServerList {
|
export class Connection {
|
||||||
private _servers: Server[] = [];
|
readonly server: Server;
|
||||||
private _serverFactory: () => Promise<Server>;
|
readonly context: Context;
|
||||||
|
|
||||||
constructor(serverFactory: () => Promise<Server>) {
|
constructor(server: Server, context: Context) {
|
||||||
this._serverFactory = serverFactory;
|
this.server = server;
|
||||||
|
this.context = context;
|
||||||
}
|
}
|
||||||
|
|
||||||
async create() {
|
async connect(transport: Transport) {
|
||||||
const server = await this._serverFactory();
|
await this.server.connect(transport);
|
||||||
this._servers.push(server);
|
await new Promise<void>(resolve => {
|
||||||
return server;
|
this.server.oninitialized = () => resolve();
|
||||||
|
});
|
||||||
|
this.context.clientVersion = this.server.getClientVersion();
|
||||||
}
|
}
|
||||||
|
|
||||||
async close(server: Server) {
|
async close() {
|
||||||
const index = this._servers.indexOf(server);
|
await this.server.close();
|
||||||
if (index !== -1)
|
await this.context.close();
|
||||||
this._servers.splice(index, 1);
|
|
||||||
await server.close();
|
|
||||||
}
|
|
||||||
|
|
||||||
async closeAll() {
|
|
||||||
await Promise.all(this._servers.map(server => server.close()));
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
208
src/context.ts
208
src/context.ts
@@ -14,36 +14,55 @@
|
|||||||
* limitations under the License.
|
* limitations under the License.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import fs from 'node:fs';
|
||||||
|
import url from 'node:url';
|
||||||
|
import os from 'node:os';
|
||||||
|
import path from 'node:path';
|
||||||
|
|
||||||
import * as playwright from 'playwright';
|
import * as playwright from 'playwright';
|
||||||
|
|
||||||
import { waitForCompletion } from './tools/utils';
|
import { callOnPageNoTrace, waitForCompletion } from './tools/utils.js';
|
||||||
import { ManualPromise } from './manualPromise';
|
import { ManualPromise } from './manualPromise.js';
|
||||||
import { Tab } from './tab';
|
import { Tab } from './tab.js';
|
||||||
|
import { outputFile } from './config.js';
|
||||||
|
|
||||||
import type { ImageContent, TextContent } from '@modelcontextprotocol/sdk/types';
|
import type { ImageContent, TextContent } from '@modelcontextprotocol/sdk/types.js';
|
||||||
import type { ModalState, Tool, ToolActionResult } from './tools/tool';
|
import type { ModalState, Tool, ToolActionResult } from './tools/tool.js';
|
||||||
import type { Config } from '../config';
|
import type { FullConfig } from './config.js';
|
||||||
|
|
||||||
type PendingAction = {
|
type PendingAction = {
|
||||||
dialogShown: ManualPromise<void>;
|
dialogShown: ManualPromise<void>;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type BrowserContextAndBrowser = {
|
||||||
|
browser?: playwright.Browser;
|
||||||
|
browserContext: playwright.BrowserContext;
|
||||||
|
};
|
||||||
|
|
||||||
export class Context {
|
export class Context {
|
||||||
readonly tools: Tool[];
|
readonly tools: Tool[];
|
||||||
readonly config: Config;
|
readonly config: FullConfig;
|
||||||
private _browser: playwright.Browser | undefined;
|
private _browserContextPromise: Promise<BrowserContextAndBrowser> | undefined;
|
||||||
private _browserContext: playwright.BrowserContext | undefined;
|
|
||||||
private _createBrowserContextPromise: Promise<{ browser?: playwright.Browser, browserContext: playwright.BrowserContext }> | undefined;
|
|
||||||
private _tabs: Tab[] = [];
|
private _tabs: Tab[] = [];
|
||||||
private _currentTab: Tab | undefined;
|
private _currentTab: Tab | undefined;
|
||||||
private _modalStates: (ModalState & { tab: Tab })[] = [];
|
private _modalStates: (ModalState & { tab: Tab })[] = [];
|
||||||
private _pendingAction: PendingAction | undefined;
|
private _pendingAction: PendingAction | undefined;
|
||||||
|
private _downloads: { download: playwright.Download, finished: boolean, outputFile: string }[] = [];
|
||||||
|
clientVersion: { name: string; version: string; } | undefined;
|
||||||
|
|
||||||
constructor(tools: Tool[], config: Config) {
|
constructor(tools: Tool[], config: FullConfig) {
|
||||||
this.tools = tools;
|
this.tools = tools;
|
||||||
this.config = config;
|
this.config = config;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
clientSupportsImages(): boolean {
|
||||||
|
if (this.config.imageResponses === 'allow')
|
||||||
|
return true;
|
||||||
|
if (this.config.imageResponses === 'omit')
|
||||||
|
return false;
|
||||||
|
return !this.clientVersion?.name.includes('cursor');
|
||||||
|
}
|
||||||
|
|
||||||
modalStates(): ModalState[] {
|
modalStates(): ModalState[] {
|
||||||
return this._modalStates;
|
return this._modalStates;
|
||||||
}
|
}
|
||||||
@@ -78,7 +97,7 @@ export class Context {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async newTab(): Promise<Tab> {
|
async newTab(): Promise<Tab> {
|
||||||
const browserContext = await this._ensureBrowserContext();
|
const { browserContext } = await this._ensureBrowserContext();
|
||||||
const page = await browserContext.newPage();
|
const page = await browserContext.newPage();
|
||||||
this._currentTab = this._tabs.find(t => t.page === page)!;
|
this._currentTab = this._tabs.find(t => t.page === page)!;
|
||||||
return this._currentTab;
|
return this._currentTab;
|
||||||
@@ -90,9 +109,9 @@ export class Context {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async ensureTab(): Promise<Tab> {
|
async ensureTab(): Promise<Tab> {
|
||||||
const context = await this._ensureBrowserContext();
|
const { browserContext } = await this._ensureBrowserContext();
|
||||||
if (!this._currentTab)
|
if (!this._currentTab)
|
||||||
await context.newPage();
|
await browserContext.newPage();
|
||||||
return this._currentTab!;
|
return this._currentTab!;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -102,7 +121,7 @@ export class Context {
|
|||||||
const lines: string[] = ['### Open tabs'];
|
const lines: string[] = ['### Open tabs'];
|
||||||
for (let i = 0; i < this._tabs.length; i++) {
|
for (let i = 0; i < this._tabs.length; i++) {
|
||||||
const tab = this._tabs[i];
|
const tab = this._tabs[i];
|
||||||
const title = await tab.page.title();
|
const title = await tab.title();
|
||||||
const url = tab.page.url();
|
const url = tab.page.url();
|
||||||
const current = tab === this._currentTab ? ' (current)' : '';
|
const current = tab === this._currentTab ? ' (current)' : '';
|
||||||
lines.push(`- ${i + 1}:${current} [${title}] (${url})`);
|
lines.push(`- ${i + 1}:${current} [${title}] (${url})`);
|
||||||
@@ -118,7 +137,7 @@ export class Context {
|
|||||||
|
|
||||||
async run(tool: Tool, params: Record<string, unknown> | undefined) {
|
async run(tool: Tool, params: Record<string, unknown> | undefined) {
|
||||||
// Tab management is done outside of the action() call.
|
// Tab management is done outside of the action() call.
|
||||||
const toolResult = await tool.handle(this, tool.schema.inputSchema.parse(params));
|
const toolResult = await tool.handle(this, tool.schema.inputSchema.parse(params || {}));
|
||||||
const { code, action, waitForNetwork, captureSnapshot, resultOverride } = toolResult;
|
const { code, action, waitForNetwork, captureSnapshot, resultOverride } = toolResult;
|
||||||
const racingAction = action ? () => this._raceAgainstModalDialogs(action) : undefined;
|
const racingAction = action ? () => this._raceAgainstModalDialogs(action) : undefined;
|
||||||
|
|
||||||
@@ -139,7 +158,7 @@ export class Context {
|
|||||||
let actionResult: { content?: (ImageContent | TextContent)[] } | undefined;
|
let actionResult: { content?: (ImageContent | TextContent)[] } | undefined;
|
||||||
try {
|
try {
|
||||||
if (waitForNetwork)
|
if (waitForNetwork)
|
||||||
actionResult = await waitForCompletion(this, tab.page, async () => racingAction?.()) ?? undefined;
|
actionResult = await waitForCompletion(this, tab, async () => racingAction?.()) ?? undefined;
|
||||||
else
|
else
|
||||||
actionResult = await racingAction?.() ?? undefined;
|
actionResult = await racingAction?.() ?? undefined;
|
||||||
} finally {
|
} finally {
|
||||||
@@ -164,6 +183,17 @@ ${code.join('\n')}
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (this._downloads.length) {
|
||||||
|
result.push('', '### Downloads');
|
||||||
|
for (const entry of this._downloads) {
|
||||||
|
if (entry.finished)
|
||||||
|
result.push(`- Downloaded file ${entry.download.suggestedFilename()} to ${entry.outputFile}`);
|
||||||
|
else
|
||||||
|
result.push(`- Downloading file ${entry.download.suggestedFilename()} ...`);
|
||||||
|
}
|
||||||
|
result.push('');
|
||||||
|
}
|
||||||
|
|
||||||
if (this.tabs().length > 1)
|
if (this.tabs().length > 1)
|
||||||
result.push(await this.listTabsMarkdown(), '');
|
result.push(await this.listTabsMarkdown(), '');
|
||||||
|
|
||||||
@@ -172,7 +202,7 @@ ${code.join('\n')}
|
|||||||
|
|
||||||
result.push(
|
result.push(
|
||||||
`- Page URL: ${tab.page.url()}`,
|
`- Page URL: ${tab.page.url()}`,
|
||||||
`- Page Title: ${await tab.page.title()}`
|
`- Page Title: ${await tab.title()}`
|
||||||
);
|
);
|
||||||
|
|
||||||
if (captureSnapshot && tab.hasSnapshot())
|
if (captureSnapshot && tab.hasSnapshot())
|
||||||
@@ -192,10 +222,14 @@ ${code.join('\n')}
|
|||||||
}
|
}
|
||||||
|
|
||||||
async waitForTimeout(time: number) {
|
async waitForTimeout(time: number) {
|
||||||
if (this._currentTab && !this._javaScriptBlocked())
|
if (!this._currentTab || this._javaScriptBlocked()) {
|
||||||
await this._currentTab.page.evaluate(() => new Promise(f => setTimeout(f, 1000)));
|
|
||||||
else
|
|
||||||
await new Promise(f => setTimeout(f, time));
|
await new Promise(f => setTimeout(f, time));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await callOnPageNoTrace(this._currentTab.page, page => {
|
||||||
|
return page.evaluate(() => new Promise(f => setTimeout(f, 1000)));
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
private async _raceAgainstModalDialogs(action: () => Promise<ToolActionResult>): Promise<ToolActionResult> {
|
private async _raceAgainstModalDialogs(action: () => Promise<ToolActionResult>): Promise<ToolActionResult> {
|
||||||
@@ -228,6 +262,17 @@ ${code.join('\n')}
|
|||||||
this._pendingAction?.dialogShown.resolve();
|
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) {
|
private _onPageCreated(page: playwright.Page) {
|
||||||
const tab = new Tab(this, page, tab => this._onPageClosed(tab));
|
const tab = new Tab(this, page, tab => this._onPageClosed(tab));
|
||||||
this._tabs.push(tab);
|
this._tabs.push(tab);
|
||||||
@@ -244,43 +289,68 @@ ${code.join('\n')}
|
|||||||
|
|
||||||
if (this._currentTab === tab)
|
if (this._currentTab === tab)
|
||||||
this._currentTab = this._tabs[Math.min(index, this._tabs.length - 1)];
|
this._currentTab = this._tabs[Math.min(index, this._tabs.length - 1)];
|
||||||
if (this._browserContext && !this._tabs.length)
|
if (!this._tabs.length)
|
||||||
void this.close();
|
void this.close();
|
||||||
}
|
}
|
||||||
|
|
||||||
async close() {
|
async close() {
|
||||||
if (!this._browserContext)
|
if (!this._browserContextPromise)
|
||||||
return;
|
return;
|
||||||
const browserContext = this._browserContext;
|
|
||||||
const browser = this._browser;
|
|
||||||
this._createBrowserContextPromise = undefined;
|
|
||||||
this._browserContext = undefined;
|
|
||||||
this._browser = undefined;
|
|
||||||
|
|
||||||
await browserContext?.close().then(async () => {
|
const promise = this._browserContextPromise;
|
||||||
await browser?.close();
|
this._browserContextPromise = undefined;
|
||||||
}).catch(() => {});
|
|
||||||
|
await promise.then(async ({ browserContext, browser }) => {
|
||||||
|
if (this.config.saveTrace)
|
||||||
|
await browserContext.tracing.stop();
|
||||||
|
await browserContext.close().then(async () => {
|
||||||
|
await browser?.close();
|
||||||
|
}).catch(() => {});
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
private async _ensureBrowserContext() {
|
private async _setupRequestInterception(context: playwright.BrowserContext) {
|
||||||
if (!this._browserContext) {
|
if (this.config.network?.allowedOrigins?.length) {
|
||||||
const context = await this._createBrowserContext();
|
await context.route('**', route => route.abort('blockedbyclient'));
|
||||||
this._browser = context.browser;
|
|
||||||
this._browserContext = context.browserContext;
|
for (const origin of this.config.network.allowedOrigins)
|
||||||
for (const page of this._browserContext.pages())
|
await context.route(`*://${origin}/**`, route => route.continue());
|
||||||
this._onPageCreated(page);
|
}
|
||||||
this._browserContext.on('page', page => this._onPageCreated(page));
|
|
||||||
|
if (this.config.network?.blockedOrigins?.length) {
|
||||||
|
for (const origin of this.config.network.blockedOrigins)
|
||||||
|
await context.route(`*://${origin}/**`, route => route.abort('blockedbyclient'));
|
||||||
}
|
}
|
||||||
return this._browserContext;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private async _createBrowserContext(): Promise<{ browser?: playwright.Browser, browserContext: playwright.BrowserContext }> {
|
private _ensureBrowserContext() {
|
||||||
if (!this._createBrowserContextPromise)
|
if (!this._browserContextPromise) {
|
||||||
this._createBrowserContextPromise = this._innerCreateBrowserContext();
|
this._browserContextPromise = this._setupBrowserContext();
|
||||||
return this._createBrowserContextPromise;
|
this._browserContextPromise.catch(() => {
|
||||||
|
this._browserContextPromise = undefined;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return this._browserContextPromise;
|
||||||
}
|
}
|
||||||
|
|
||||||
private async _innerCreateBrowserContext(): Promise<{ browser?: playwright.Browser, browserContext: playwright.BrowserContext }> {
|
private async _setupBrowserContext(): Promise<BrowserContextAndBrowser> {
|
||||||
|
const { browser, browserContext } = await this._createBrowserContext();
|
||||||
|
await this._setupRequestInterception(browserContext);
|
||||||
|
for (const page of browserContext.pages())
|
||||||
|
this._onPageCreated(page);
|
||||||
|
browserContext.on('page', page => this._onPageCreated(page));
|
||||||
|
if (this.config.saveTrace) {
|
||||||
|
await browserContext.tracing.start({
|
||||||
|
name: 'trace',
|
||||||
|
screenshots: false,
|
||||||
|
snapshots: true,
|
||||||
|
sources: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return { browser, browserContext };
|
||||||
|
}
|
||||||
|
|
||||||
|
private async _createBrowserContext(): Promise<BrowserContextAndBrowser> {
|
||||||
if (this.config.browser?.remoteEndpoint) {
|
if (this.config.browser?.remoteEndpoint) {
|
||||||
const url = new URL(this.config.browser?.remoteEndpoint);
|
const url = new URL(this.config.browser?.remoteEndpoint);
|
||||||
if (this.config.browser.browserName)
|
if (this.config.browser.browserName)
|
||||||
@@ -294,19 +364,23 @@ ${code.join('\n')}
|
|||||||
|
|
||||||
if (this.config.browser?.cdpEndpoint) {
|
if (this.config.browser?.cdpEndpoint) {
|
||||||
const browser = await playwright.chromium.connectOverCDP(this.config.browser.cdpEndpoint);
|
const browser = await playwright.chromium.connectOverCDP(this.config.browser.cdpEndpoint);
|
||||||
const browserContext = browser.contexts()[0];
|
const browserContext = this.config.browser.isolated ? await browser.newContext() : browser.contexts()[0];
|
||||||
return { browser, browserContext };
|
return { browser, browserContext };
|
||||||
}
|
}
|
||||||
|
|
||||||
const browserContext = await launchPersistentContext(this.config.browser);
|
return this.config.browser?.isolated ?
|
||||||
return { browserContext };
|
await createIsolatedContext(this.config.browser) :
|
||||||
|
await launchPersistentContext(this.config.browser);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function launchPersistentContext(browserConfig: Config['browser']): Promise<playwright.BrowserContext> {
|
async function createIsolatedContext(browserConfig: FullConfig['browser']): Promise<BrowserContextAndBrowser> {
|
||||||
try {
|
try {
|
||||||
const browserType = browserConfig?.browserName ? playwright[browserConfig.browserName] : playwright.chromium;
|
const browserName = browserConfig?.browserName ?? 'chromium';
|
||||||
return await browserType.launchPersistentContext(browserConfig?.userDataDir || '', { ...browserConfig?.launchOptions, ...browserConfig?.contextOptions });
|
const browserType = playwright[browserName];
|
||||||
|
const browser = await browserType.launch(browserConfig.launchOptions);
|
||||||
|
const browserContext = await browser.newContext(browserConfig.contextOptions);
|
||||||
|
return { browser, browserContext };
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
if (error.message.includes('Executable doesn\'t exist'))
|
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 new Error(`Browser specified in your config is not installed. Either install it (likely) or change the config.`);
|
||||||
@@ -314,6 +388,34 @@ async function launchPersistentContext(browserConfig: Config['browser']): Promis
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function generateLocator(locator: playwright.Locator): Promise<string> {
|
async function launchPersistentContext(browserConfig: FullConfig['browser']): Promise<BrowserContextAndBrowser> {
|
||||||
return (locator as any)._generateLocatorString();
|
try {
|
||||||
|
const browserName = browserConfig.browserName ?? 'chromium';
|
||||||
|
const userDataDir = browserConfig.userDataDir ?? await createUserDataDir({ ...browserConfig, browserName });
|
||||||
|
const browserType = playwright[browserName];
|
||||||
|
const browserContext = await browserType.launchPersistentContext(userDataDir, { ...browserConfig.launchOptions, ...browserConfig.contextOptions });
|
||||||
|
return { browserContext };
|
||||||
|
} catch (error: any) {
|
||||||
|
if (error.message.includes('Executable doesn\'t exist'))
|
||||||
|
throw new Error(`Browser specified in your config is not installed. Either install it (likely) or change the config.`);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function createUserDataDir(browserConfig: FullConfig['browser']) {
|
||||||
|
let cacheDirectory: string;
|
||||||
|
if (process.platform === 'linux')
|
||||||
|
cacheDirectory = process.env.XDG_CACHE_HOME || path.join(os.homedir(), '.cache');
|
||||||
|
else if (process.platform === 'darwin')
|
||||||
|
cacheDirectory = path.join(os.homedir(), 'Library', 'Caches');
|
||||||
|
else if (process.platform === 'win32')
|
||||||
|
cacheDirectory = process.env.LOCALAPPDATA || path.join(os.homedir(), 'AppData', 'Local');
|
||||||
|
else
|
||||||
|
throw new Error('Unsupported platform: ' + process.platform);
|
||||||
|
const result = path.join(cacheDirectory, 'ms-playwright', `mcp-${browserConfig.launchOptions?.channel ?? browserConfig?.browserName}-profile`);
|
||||||
|
await fs.promises.mkdir(result, { recursive: true });
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
const __filename = url.fileURLToPath(import.meta.url);
|
||||||
|
export const packageJSON = JSON.parse(fs.readFileSync(path.join(path.dirname(__filename), '..', 'package.json'), 'utf8'));
|
||||||
|
|||||||
60
src/index.ts
60
src/index.ts
@@ -14,60 +14,12 @@
|
|||||||
* limitations under the License.
|
* limitations under the License.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { createServerWithTools } from './server';
|
import { Connection, createConnection as createConnectionImpl } from './connection.js';
|
||||||
import common from './tools/common';
|
import { resolveConfig } from './config.js';
|
||||||
import console from './tools/console';
|
|
||||||
import dialogs from './tools/dialogs';
|
|
||||||
import files from './tools/files';
|
|
||||||
import install from './tools/install';
|
|
||||||
import keyboard from './tools/keyboard';
|
|
||||||
import navigate from './tools/navigate';
|
|
||||||
import network from './tools/network';
|
|
||||||
import pdf from './tools/pdf';
|
|
||||||
import snapshot from './tools/snapshot';
|
|
||||||
import tabs from './tools/tabs';
|
|
||||||
import screen from './tools/screen';
|
|
||||||
|
|
||||||
import type { Tool } from './tools/tool';
|
import type { Config } from '../config.js';
|
||||||
import type { Config } from '../config';
|
|
||||||
import type { Server } from '@modelcontextprotocol/sdk/server/index.js';
|
|
||||||
|
|
||||||
const snapshotTools: Tool<any>[] = [
|
export async function createConnection(userConfig: Config = {}): Promise<Connection> {
|
||||||
...common(true),
|
const config = await resolveConfig(userConfig);
|
||||||
...console,
|
return createConnectionImpl(config);
|
||||||
...dialogs(true),
|
|
||||||
...files(true),
|
|
||||||
...install,
|
|
||||||
...keyboard(true),
|
|
||||||
...navigate(true),
|
|
||||||
...network,
|
|
||||||
...pdf,
|
|
||||||
...snapshot,
|
|
||||||
...tabs(true),
|
|
||||||
];
|
|
||||||
|
|
||||||
const screenshotTools: Tool<any>[] = [
|
|
||||||
...common(false),
|
|
||||||
...console,
|
|
||||||
...dialogs(false),
|
|
||||||
...files(false),
|
|
||||||
...install,
|
|
||||||
...keyboard(false),
|
|
||||||
...navigate(false),
|
|
||||||
...network,
|
|
||||||
...pdf,
|
|
||||||
...screen,
|
|
||||||
...tabs(false),
|
|
||||||
];
|
|
||||||
|
|
||||||
const packageJSON = require('../package.json');
|
|
||||||
|
|
||||||
export async function createServer(config: Config = {}): Promise<Server> {
|
|
||||||
const allTools = config.vision ? screenshotTools : snapshotTools;
|
|
||||||
const tools = allTools.filter(tool => !config.capabilities || tool.capability === 'core' || config.capabilities.includes(tool.capability));
|
|
||||||
return createServerWithTools({
|
|
||||||
name: 'Playwright',
|
|
||||||
version: packageJSON.version,
|
|
||||||
tools,
|
|
||||||
}, config);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,20 +15,23 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import * as playwright from 'playwright';
|
import * as playwright from 'playwright';
|
||||||
import yaml from 'yaml';
|
import { callOnPageNoTrace } from './tools/utils.js';
|
||||||
|
|
||||||
type PageOrFrameLocator = playwright.Page | playwright.FrameLocator;
|
type PageEx = playwright.Page & {
|
||||||
|
_snapshotForAI: () => Promise<string>;
|
||||||
|
};
|
||||||
|
|
||||||
export class PageSnapshot {
|
export class PageSnapshot {
|
||||||
private _frameLocators: PageOrFrameLocator[] = [];
|
private _page: playwright.Page;
|
||||||
private _text!: string;
|
private _text!: string;
|
||||||
|
|
||||||
constructor() {
|
constructor(page: playwright.Page) {
|
||||||
|
this._page = page;
|
||||||
}
|
}
|
||||||
|
|
||||||
static async create(page: playwright.Page): Promise<PageSnapshot> {
|
static async create(page: playwright.Page): Promise<PageSnapshot> {
|
||||||
const snapshot = new PageSnapshot();
|
const snapshot = new PageSnapshot(page);
|
||||||
await snapshot._build(page);
|
await snapshot._build();
|
||||||
return snapshot;
|
return snapshot;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -36,66 +39,17 @@ export class PageSnapshot {
|
|||||||
return this._text;
|
return this._text;
|
||||||
}
|
}
|
||||||
|
|
||||||
private async _build(page: playwright.Page) {
|
private async _build() {
|
||||||
const yamlDocument = await this._snapshotFrame(page);
|
const snapshot = await callOnPageNoTrace(this._page, page => (page as PageEx)._snapshotForAI());
|
||||||
this._text = [
|
this._text = [
|
||||||
`- Page Snapshot`,
|
`- Page Snapshot`,
|
||||||
'```yaml',
|
'```yaml',
|
||||||
yamlDocument.toString({ indentSeq: false }).trim(),
|
snapshot,
|
||||||
'```',
|
'```',
|
||||||
].join('\n');
|
].join('\n');
|
||||||
}
|
}
|
||||||
|
|
||||||
private async _snapshotFrame(frame: playwright.Page | playwright.FrameLocator) {
|
refLocator(params: { element: string, ref: string }): playwright.Locator {
|
||||||
const frameIndex = this._frameLocators.push(frame) - 1;
|
return this._page.locator(`aria-ref=${params.ref}`).describe(params.element);
|
||||||
const snapshotString = await frame.locator('body').ariaSnapshot({ ref: true, emitGeneric: 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._snapshotFrame(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._frameLocators[0];
|
|
||||||
const match = ref.match(/^f(\d+)(.*)/);
|
|
||||||
if (match) {
|
|
||||||
const frameIndex = parseInt(match[1], 10);
|
|
||||||
frame = this._frameLocators[frameIndex];
|
|
||||||
ref = match[2];
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!frame)
|
|
||||||
throw new Error(`Frame does not exist. Provide ref from the most current snapshot.`);
|
|
||||||
|
|
||||||
return frame.locator(`aria-ref=${ref}`);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,44 +16,66 @@
|
|||||||
|
|
||||||
import { program } from 'commander';
|
import { program } from 'commander';
|
||||||
|
|
||||||
import { createServer } from './index';
|
import { startHttpTransport, startStdioTransport } from './transport.js';
|
||||||
import { ServerList } from './server';
|
import { resolveCLIConfig } from './config.js';
|
||||||
|
// @ts-ignore
|
||||||
|
import { startTraceViewerServer } from 'playwright-core/lib/server';
|
||||||
|
|
||||||
import { startHttpTransport, startStdioTransport } from './transport';
|
import type { Connection } from './connection.js';
|
||||||
|
import { packageJSON } from './context.js';
|
||||||
import { resolveConfig } from './config';
|
|
||||||
|
|
||||||
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('--caps <caps>', 'Comma-separated list of capabilities to enable, possible values: tabs, pdf, history, wait, files, install. Default is all.')
|
.option('--blocked-origins <origins>', 'semicolon-separated list of origins to block the browser from requesting. Blocklist is evaluated before allowlist. If used without the allowlist, requests not matching the blocklist are still allowed.', semicolonSeparatedList)
|
||||||
|
.option('--block-service-workers', 'block service workers')
|
||||||
|
.option('--browser <browser>', 'browser or chrome channel to use, possible values: chrome, firefox, webkit, msedge.')
|
||||||
|
.option('--caps <caps>', 'comma-separated list of capabilities to enable, possible values: tabs, pdf, history, wait, files, install. Default is all.')
|
||||||
.option('--cdp-endpoint <endpoint>', 'CDP endpoint to connect to.')
|
.option('--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('--device <device>', 'Device to emulate, for example: "iPhone 15"')
|
.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('--port <port>', 'Port to listen on for SSE transport.')
|
.option('--host <host>', 'host to bind server to. Default is localhost. Use 0.0.0.0 to bind to all interfaces.')
|
||||||
.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)')
|
||||||
.option('--config <path>', 'Path to the configuration file.')
|
|
||||||
.action(async options => {
|
.action(async options => {
|
||||||
const config = await resolveConfig(options);
|
const config = await resolveCLIConfig(options);
|
||||||
const serverList = new ServerList(() => createServer(config));
|
const connectionList: Connection[] = [];
|
||||||
setupExitWatchdog(serverList);
|
setupExitWatchdog(connectionList);
|
||||||
|
|
||||||
if (options.port)
|
if (options.port)
|
||||||
startHttpTransport(+options.port, options.host, serverList);
|
startHttpTransport(config, +options.port, options.host, connectionList);
|
||||||
else
|
else
|
||||||
await startStdioTransport(serverList);
|
await startStdioTransport(config, connectionList);
|
||||||
|
|
||||||
|
if (config.saveTrace) {
|
||||||
|
const server = await startTraceViewerServer();
|
||||||
|
const urlPrefix = server.urlPrefix('human-readable');
|
||||||
|
const url = urlPrefix + '/trace/index.html?trace=' + config.browser.launchOptions.tracesDir + '/trace.json';
|
||||||
|
// eslint-disable-next-line no-console
|
||||||
|
console.error('\nTrace viewer listening on ' + url);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
function setupExitWatchdog(serverList: ServerList) {
|
function setupExitWatchdog(connectionList: Connection[]) {
|
||||||
const handleExit = async () => {
|
const handleExit = async () => {
|
||||||
setTimeout(() => process.exit(0), 15000);
|
setTimeout(() => process.exit(0), 15000);
|
||||||
await serverList.closeAll();
|
for (const connection of connectionList)
|
||||||
|
await connection.close();
|
||||||
process.exit(0);
|
process.exit(0);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -62,4 +84,8 @@ function setupExitWatchdog(serverList: ServerList) {
|
|||||||
process.on('SIGTERM', handleExit);
|
process.on('SIGTERM', handleExit);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function semicolonSeparatedList(value: string): string[] {
|
||||||
|
return value.split(';').map(v => v.trim());
|
||||||
|
}
|
||||||
|
|
||||||
program.parse(process.argv);
|
program.parse(process.argv);
|
||||||
|
|||||||
@@ -14,7 +14,7 @@
|
|||||||
* limitations under the License.
|
* limitations under the License.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import type { Context } from '../context';
|
import type { Context } from '../context.js';
|
||||||
|
|
||||||
export type ResourceSchema = {
|
export type ResourceSchema = {
|
||||||
uri: string;
|
uri: string;
|
||||||
|
|||||||
55
src/tab.ts
55
src/tab.ts
@@ -16,14 +16,15 @@
|
|||||||
|
|
||||||
import * as playwright from 'playwright';
|
import * as playwright from 'playwright';
|
||||||
|
|
||||||
import { PageSnapshot } from './pageSnapshot';
|
import { PageSnapshot } from './pageSnapshot.js';
|
||||||
|
|
||||||
import type { Context } from './context';
|
import type { Context } from './context.js';
|
||||||
|
import { callOnPageNoTrace } from './tools/utils.js';
|
||||||
|
|
||||||
export class Tab {
|
export class Tab {
|
||||||
readonly context: Context;
|
readonly context: Context;
|
||||||
readonly page: playwright.Page;
|
readonly page: playwright.Page;
|
||||||
private _console: playwright.ConsoleMessage[] = [];
|
private _consoleMessages: playwright.ConsoleMessage[] = [];
|
||||||
private _requests: Map<playwright.Request, playwright.Response | null> = new Map();
|
private _requests: Map<playwright.Request, playwright.Response | null> = new Map();
|
||||||
private _snapshot: PageSnapshot | undefined;
|
private _snapshot: PageSnapshot | undefined;
|
||||||
private _onPageClose: (tab: Tab) => void;
|
private _onPageClose: (tab: Tab) => void;
|
||||||
@@ -32,13 +33,9 @@ export class Tab {
|
|||||||
this.context = context;
|
this.context = context;
|
||||||
this.page = page;
|
this.page = page;
|
||||||
this._onPageClose = onPageClose;
|
this._onPageClose = onPageClose;
|
||||||
page.on('console', event => this._console.push(event));
|
page.on('console', event => this._consoleMessages.push(event));
|
||||||
page.on('request', request => this._requests.set(request, null));
|
page.on('request', request => this._requests.set(request, null));
|
||||||
page.on('response', response => this._requests.set(response.request(), response));
|
page.on('response', response => this._requests.set(response.request(), response));
|
||||||
page.on('framenavigated', frame => {
|
|
||||||
if (!frame.parentFrame())
|
|
||||||
this._clearCollectedArtifacts();
|
|
||||||
});
|
|
||||||
page.on('close', () => this._onClose());
|
page.on('close', () => this._onClose());
|
||||||
page.on('filechooser', chooser => {
|
page.on('filechooser', chooser => {
|
||||||
this.context.setModalState({
|
this.context.setModalState({
|
||||||
@@ -48,12 +45,15 @@ export class Tab {
|
|||||||
}, this);
|
}, this);
|
||||||
});
|
});
|
||||||
page.on('dialog', dialog => this.context.dialogShown(this, dialog));
|
page.on('dialog', dialog => this.context.dialogShown(this, dialog));
|
||||||
|
page.on('download', download => {
|
||||||
|
void this.context.downloadStarted(this, download);
|
||||||
|
});
|
||||||
page.setDefaultNavigationTimeout(60000);
|
page.setDefaultNavigationTimeout(60000);
|
||||||
page.setDefaultTimeout(5000);
|
page.setDefaultTimeout(5000);
|
||||||
}
|
}
|
||||||
|
|
||||||
private _clearCollectedArtifacts() {
|
private _clearCollectedArtifacts() {
|
||||||
this._console.length = 0;
|
this._consoleMessages.length = 0;
|
||||||
this._requests.clear();
|
this._requests.clear();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -62,10 +62,39 @@ export class Tab {
|
|||||||
this._onPageClose(this);
|
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) {
|
async navigate(url: string) {
|
||||||
await this.page.goto(url, { waitUntil: 'domcontentloaded' });
|
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, 500)),
|
||||||
|
]);
|
||||||
|
if (!download)
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
|
||||||
// Cap load event to 5 seconds, the page is operational at this point.
|
// Cap load event to 5 seconds, the page is operational at this point.
|
||||||
await this.page.waitForLoadState('load', { timeout: 5000 }).catch(() => {});
|
await this.waitForLoadState('load', { timeout: 5000 });
|
||||||
}
|
}
|
||||||
|
|
||||||
hasSnapshot(): boolean {
|
hasSnapshot(): boolean {
|
||||||
@@ -78,8 +107,8 @@ export class Tab {
|
|||||||
return this._snapshot;
|
return this._snapshot;
|
||||||
}
|
}
|
||||||
|
|
||||||
console(): playwright.ConsoleMessage[] {
|
consoleMessages(): playwright.ConsoleMessage[] {
|
||||||
return this._console;
|
return this._consoleMessages;
|
||||||
}
|
}
|
||||||
|
|
||||||
requests(): Map<playwright.Request, playwright.Response | null> {
|
requests(): Map<playwright.Request, playwright.Response | null> {
|
||||||
|
|||||||
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),
|
||||||
|
];
|
||||||
@@ -15,42 +15,23 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
import { defineTool, type ToolFactory } from './tool';
|
import { defineTool, type ToolFactory } from './tool.js';
|
||||||
|
|
||||||
const wait: ToolFactory = captureSnapshot => defineTool({
|
|
||||||
capability: 'wait',
|
|
||||||
|
|
||||||
schema: {
|
|
||||||
name: 'browser_wait',
|
|
||||||
description: 'Wait for a specified time in seconds',
|
|
||||||
inputSchema: z.object({
|
|
||||||
time: z.number().describe('The time to wait in seconds'),
|
|
||||||
}),
|
|
||||||
},
|
|
||||||
|
|
||||||
handle: async (context, params) => {
|
|
||||||
await new Promise(f => setTimeout(f, Math.min(10000, params.time * 1000)));
|
|
||||||
return {
|
|
||||||
code: [`// Waited for ${params.time} seconds`],
|
|
||||||
captureSnapshot,
|
|
||||||
waitForNetwork: false,
|
|
||||||
};
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const close = defineTool({
|
const close = defineTool({
|
||||||
capability: 'core',
|
capability: 'core',
|
||||||
|
|
||||||
schema: {
|
schema: {
|
||||||
name: 'browser_close',
|
name: 'browser_close',
|
||||||
|
title: 'Close browser',
|
||||||
description: 'Close the page',
|
description: 'Close the page',
|
||||||
inputSchema: z.object({}),
|
inputSchema: z.object({}),
|
||||||
|
type: 'readOnly',
|
||||||
},
|
},
|
||||||
|
|
||||||
handle: async context => {
|
handle: async context => {
|
||||||
await context.close();
|
await context.close();
|
||||||
return {
|
return {
|
||||||
code: [`// Internal to close the page`],
|
code: [`await page.close()`],
|
||||||
captureSnapshot: false,
|
captureSnapshot: false,
|
||||||
waitForNetwork: false,
|
waitForNetwork: false,
|
||||||
};
|
};
|
||||||
@@ -61,11 +42,13 @@ const resize: ToolFactory = captureSnapshot => defineTool({
|
|||||||
capability: 'core',
|
capability: 'core',
|
||||||
schema: {
|
schema: {
|
||||||
name: 'browser_resize',
|
name: 'browser_resize',
|
||||||
|
title: 'Resize browser window',
|
||||||
description: 'Resize the browser window',
|
description: 'Resize the browser window',
|
||||||
inputSchema: z.object({
|
inputSchema: z.object({
|
||||||
width: z.number().describe('Width of the browser window'),
|
width: z.number().describe('Width of the browser window'),
|
||||||
height: z.number().describe('Height of the browser window'),
|
height: z.number().describe('Height of the browser window'),
|
||||||
}),
|
}),
|
||||||
|
type: 'readOnly',
|
||||||
},
|
},
|
||||||
|
|
||||||
handle: async (context, params) => {
|
handle: async (context, params) => {
|
||||||
@@ -91,6 +74,5 @@ const resize: ToolFactory = captureSnapshot => defineTool({
|
|||||||
|
|
||||||
export default (captureSnapshot: boolean) => [
|
export default (captureSnapshot: boolean) => [
|
||||||
close,
|
close,
|
||||||
wait(captureSnapshot),
|
|
||||||
resize(captureSnapshot)
|
resize(captureSnapshot)
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -15,17 +15,19 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
import { defineTool } from './tool';
|
import { defineTool } from './tool.js';
|
||||||
|
|
||||||
const console = defineTool({
|
const console = defineTool({
|
||||||
capability: 'core',
|
capability: 'core',
|
||||||
schema: {
|
schema: {
|
||||||
name: 'browser_console_messages',
|
name: 'browser_console_messages',
|
||||||
|
title: 'Get console messages',
|
||||||
description: 'Returns all console messages',
|
description: 'Returns all console messages',
|
||||||
inputSchema: z.object({}),
|
inputSchema: z.object({}),
|
||||||
|
type: 'readOnly',
|
||||||
},
|
},
|
||||||
handle: async context => {
|
handle: async context => {
|
||||||
const messages = context.currentTabOrDie().console();
|
const messages = context.currentTabOrDie().consoleMessages();
|
||||||
const log = messages.map(message => `[${message.type().toUpperCase()}] ${message.text()}`).join('\n');
|
const log = messages.map(message => `[${message.type().toUpperCase()}] ${message.text()}`).join('\n');
|
||||||
return {
|
return {
|
||||||
code: [`// <internal code to get console messages>`],
|
code: [`// <internal code to get console messages>`],
|
||||||
|
|||||||
@@ -15,18 +15,20 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
import { defineTool, type ToolFactory } from './tool';
|
import { defineTool, type ToolFactory } from './tool.js';
|
||||||
|
|
||||||
const handleDialog: ToolFactory = captureSnapshot => defineTool({
|
const handleDialog: ToolFactory = captureSnapshot => defineTool({
|
||||||
capability: 'core',
|
capability: 'core',
|
||||||
|
|
||||||
schema: {
|
schema: {
|
||||||
name: 'browser_handle_dialog',
|
name: 'browser_handle_dialog',
|
||||||
|
title: 'Handle a dialog',
|
||||||
description: 'Handle a dialog',
|
description: 'Handle a dialog',
|
||||||
inputSchema: z.object({
|
inputSchema: z.object({
|
||||||
accept: z.boolean().describe('Whether to accept the dialog.'),
|
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.'),
|
promptText: z.string().optional().describe('The text of the prompt in case of a prompt dialog.'),
|
||||||
}),
|
}),
|
||||||
|
type: 'destructive',
|
||||||
},
|
},
|
||||||
|
|
||||||
handle: async (context, params) => {
|
handle: async (context, params) => {
|
||||||
|
|||||||
@@ -15,17 +15,19 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
import { defineTool, type ToolFactory } from './tool';
|
import { defineTool, type ToolFactory } from './tool.js';
|
||||||
|
|
||||||
const uploadFile: ToolFactory = captureSnapshot => defineTool({
|
const uploadFile: ToolFactory = captureSnapshot => defineTool({
|
||||||
capability: 'files',
|
capability: 'files',
|
||||||
|
|
||||||
schema: {
|
schema: {
|
||||||
name: 'browser_file_upload',
|
name: 'browser_file_upload',
|
||||||
|
title: 'Upload files',
|
||||||
description: 'Upload one or multiple files',
|
description: 'Upload one or multiple files',
|
||||||
inputSchema: z.object({
|
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.'),
|
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) => {
|
handle: async (context, params) => {
|
||||||
|
|||||||
@@ -18,20 +18,25 @@ import { fork } from 'child_process';
|
|||||||
import path from 'path';
|
import path from 'path';
|
||||||
|
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
import { defineTool } from './tool';
|
import { defineTool } from './tool.js';
|
||||||
|
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
|
||||||
const install = defineTool({
|
const install = defineTool({
|
||||||
capability: 'install',
|
capability: 'install',
|
||||||
schema: {
|
schema: {
|
||||||
name: 'browser_install',
|
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.',
|
description: 'Install the browser specified in the config. Call this if you get an error about the browser not being installed.',
|
||||||
inputSchema: z.object({}),
|
inputSchema: z.object({}),
|
||||||
|
type: 'destructive',
|
||||||
},
|
},
|
||||||
|
|
||||||
handle: async context => {
|
handle: async context => {
|
||||||
const channel = context.config.browser?.launchOptions?.channel ?? context.config.browser?.launchOptions.browserName ?? 'chrome';
|
const channel = context.config.browser?.launchOptions?.channel ?? context.config.browser?.browserName ?? 'chrome';
|
||||||
const cli = path.join(require.resolve('playwright/package.json'), '..', 'cli.js');
|
const cliUrl = import.meta.resolve('playwright/package.json');
|
||||||
const child = fork(cli, ['install', channel], {
|
const cliPath = path.join(fileURLToPath(cliUrl), '..', 'cli.js');
|
||||||
|
const child = fork(cliPath, ['install', channel], {
|
||||||
stdio: 'pipe',
|
stdio: 'pipe',
|
||||||
});
|
});
|
||||||
const output: string[] = [];
|
const output: string[] = [];
|
||||||
|
|||||||
@@ -15,17 +15,19 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
import { defineTool, type ToolFactory } from './tool';
|
import { defineTool, type ToolFactory } from './tool.js';
|
||||||
|
|
||||||
const pressKey: ToolFactory = captureSnapshot => defineTool({
|
const pressKey: ToolFactory = captureSnapshot => defineTool({
|
||||||
capability: 'core',
|
capability: 'core',
|
||||||
|
|
||||||
schema: {
|
schema: {
|
||||||
name: 'browser_press_key',
|
name: 'browser_press_key',
|
||||||
|
title: 'Press a key',
|
||||||
description: 'Press a key on the keyboard',
|
description: 'Press a key on the keyboard',
|
||||||
inputSchema: z.object({
|
inputSchema: z.object({
|
||||||
key: z.string().describe('Name of the key to press or a character to generate, such as `ArrowLeft` or `a`'),
|
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) => {
|
handle: async (context, params) => {
|
||||||
|
|||||||
@@ -15,17 +15,19 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
import { defineTool, type ToolFactory } from './tool';
|
import { defineTool, type ToolFactory } from './tool.js';
|
||||||
|
|
||||||
const navigate: ToolFactory = captureSnapshot => defineTool({
|
const navigate: ToolFactory = captureSnapshot => defineTool({
|
||||||
capability: 'core',
|
capability: 'core',
|
||||||
|
|
||||||
schema: {
|
schema: {
|
||||||
name: 'browser_navigate',
|
name: 'browser_navigate',
|
||||||
|
title: 'Navigate to a URL',
|
||||||
description: 'Navigate to a URL',
|
description: 'Navigate to a URL',
|
||||||
inputSchema: z.object({
|
inputSchema: z.object({
|
||||||
url: z.string().describe('The URL to navigate to'),
|
url: z.string().describe('The URL to navigate to'),
|
||||||
}),
|
}),
|
||||||
|
type: 'destructive',
|
||||||
},
|
},
|
||||||
|
|
||||||
handle: async (context, params) => {
|
handle: async (context, params) => {
|
||||||
@@ -49,8 +51,10 @@ const goBack: ToolFactory = captureSnapshot => defineTool({
|
|||||||
capability: 'history',
|
capability: 'history',
|
||||||
schema: {
|
schema: {
|
||||||
name: 'browser_navigate_back',
|
name: 'browser_navigate_back',
|
||||||
|
title: 'Go back',
|
||||||
description: 'Go back to the previous page',
|
description: 'Go back to the previous page',
|
||||||
inputSchema: z.object({}),
|
inputSchema: z.object({}),
|
||||||
|
type: 'readOnly',
|
||||||
},
|
},
|
||||||
|
|
||||||
handle: async context => {
|
handle: async context => {
|
||||||
@@ -73,8 +77,10 @@ const goForward: ToolFactory = captureSnapshot => defineTool({
|
|||||||
capability: 'history',
|
capability: 'history',
|
||||||
schema: {
|
schema: {
|
||||||
name: 'browser_navigate_forward',
|
name: 'browser_navigate_forward',
|
||||||
|
title: 'Go forward',
|
||||||
description: 'Go forward to the next page',
|
description: 'Go forward to the next page',
|
||||||
inputSchema: z.object({}),
|
inputSchema: z.object({}),
|
||||||
|
type: 'readOnly',
|
||||||
},
|
},
|
||||||
handle: async context => {
|
handle: async context => {
|
||||||
const tab = context.currentTabOrDie();
|
const tab = context.currentTabOrDie();
|
||||||
|
|||||||
@@ -15,7 +15,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
import { defineTool } from './tool';
|
import { defineTool } from './tool.js';
|
||||||
|
|
||||||
import type * as playwright from 'playwright';
|
import type * as playwright from 'playwright';
|
||||||
|
|
||||||
@@ -24,8 +24,10 @@ const requests = defineTool({
|
|||||||
|
|
||||||
schema: {
|
schema: {
|
||||||
name: 'browser_network_requests',
|
name: 'browser_network_requests',
|
||||||
|
title: 'List network requests',
|
||||||
description: 'Returns all network requests since loading the page',
|
description: 'Returns all network requests since loading the page',
|
||||||
inputSchema: z.object({}),
|
inputSchema: z.object({}),
|
||||||
|
type: 'readOnly',
|
||||||
},
|
},
|
||||||
|
|
||||||
handle: async context => {
|
handle: async context => {
|
||||||
|
|||||||
@@ -15,23 +15,29 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
import { defineTool } from './tool';
|
import { defineTool } from './tool.js';
|
||||||
|
|
||||||
import * as javascript from '../javascript';
|
import * as javascript from '../javascript.js';
|
||||||
import { outputFile } from '../config';
|
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({
|
const pdf = defineTool({
|
||||||
capability: 'pdf',
|
capability: 'pdf',
|
||||||
|
|
||||||
schema: {
|
schema: {
|
||||||
name: 'browser_pdf_save',
|
name: 'browser_pdf_save',
|
||||||
|
title: 'Save as PDF',
|
||||||
description: 'Save page as PDF',
|
description: 'Save page as PDF',
|
||||||
inputSchema: z.object({}),
|
inputSchema: pdfSchema,
|
||||||
|
type: 'readOnly',
|
||||||
},
|
},
|
||||||
|
|
||||||
handle: async context => {
|
handle: async (context, params) => {
|
||||||
const tab = context.currentTabOrDie();
|
const tab = context.currentTabOrDie();
|
||||||
const fileName = await outputFile(context.config, `page-${new Date().toISOString()}'.pdf'`);
|
const fileName = await outputFile(context.config, params.filename ?? `page-${new Date().toISOString()}.pdf`);
|
||||||
|
|
||||||
const code = [
|
const code = [
|
||||||
`// Save page as ${fileName}`,
|
`// Save page as ${fileName}`,
|
||||||
|
|||||||
90
src/tools/screenshot.ts
Normal file
90
src/tools/screenshot.ts
Normal file
@@ -0,0 +1,90 @@
|
|||||||
|
/**
|
||||||
|
* Copyright (c) Microsoft Corporation.
|
||||||
|
*
|
||||||
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
* you may not use this file except in compliance with the License.
|
||||||
|
* You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing, software
|
||||||
|
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
* See the License for the specific language governing permissions and
|
||||||
|
* limitations under the License.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
import { defineTool } from './tool.js';
|
||||||
|
import * as javascript from '../javascript.js';
|
||||||
|
import { outputFile } from '../config.js';
|
||||||
|
import { generateLocator } from './utils.js';
|
||||||
|
|
||||||
|
import type * as playwright from 'playwright';
|
||||||
|
|
||||||
|
const screenshotSchema = z.object({
|
||||||
|
raw: z.boolean().optional().describe('Whether to return without compression (in PNG format). Default is false, which returns a JPEG image.'),
|
||||||
|
filename: z.string().optional().describe('File name to save the screenshot to. Defaults to `page-{timestamp}.{png|jpeg}` if not specified.'),
|
||||||
|
element: z.string().optional().describe('Human-readable element description used to obtain permission to screenshot the element. If not provided, the screenshot will be taken of viewport. If element is provided, ref must be provided too.'),
|
||||||
|
ref: z.string().optional().describe('Exact target element reference from the page snapshot. If not provided, the screenshot will be taken of viewport. If ref is provided, element must be provided too.'),
|
||||||
|
}).refine(data => {
|
||||||
|
return !!data.element === !!data.ref;
|
||||||
|
}, {
|
||||||
|
message: 'Both element and ref must be provided or neither.',
|
||||||
|
path: ['ref', 'element']
|
||||||
|
});
|
||||||
|
|
||||||
|
const screenshot = defineTool({
|
||||||
|
capability: 'core',
|
||||||
|
schema: {
|
||||||
|
name: 'browser_take_screenshot',
|
||||||
|
title: 'Take a screenshot',
|
||||||
|
description: `Take a screenshot of the current page. You can't perform actions based on the screenshot, use browser_snapshot for actions.`,
|
||||||
|
inputSchema: screenshotSchema,
|
||||||
|
type: 'readOnly',
|
||||||
|
},
|
||||||
|
|
||||||
|
handle: async (context, params) => {
|
||||||
|
const tab = context.currentTabOrDie();
|
||||||
|
const snapshot = tab.snapshotOrDie();
|
||||||
|
const fileType = params.raw ? 'png' : 'jpeg';
|
||||||
|
const fileName = await outputFile(context.config, params.filename ?? `page-${new Date().toISOString()}.${fileType}`);
|
||||||
|
const options: playwright.PageScreenshotOptions = { type: fileType, quality: fileType === 'png' ? undefined : 50, scale: 'css', path: fileName };
|
||||||
|
const isElementScreenshot = params.element && params.ref;
|
||||||
|
|
||||||
|
const code = [
|
||||||
|
`// Screenshot ${isElementScreenshot ? params.element : 'viewport'} and save it as ${fileName}`,
|
||||||
|
];
|
||||||
|
|
||||||
|
const locator = params.ref ? snapshot.refLocator({ element: params.element || '', ref: params.ref }) : null;
|
||||||
|
|
||||||
|
if (locator)
|
||||||
|
code.push(`await page.${await generateLocator(locator)}.screenshot(${javascript.formatObject(options)});`);
|
||||||
|
else
|
||||||
|
code.push(`await page.screenshot(${javascript.formatObject(options)});`);
|
||||||
|
|
||||||
|
const includeBase64 = context.clientSupportsImages();
|
||||||
|
const action = async () => {
|
||||||
|
const screenshot = locator ? await locator.screenshot(options) : await tab.page.screenshot(options);
|
||||||
|
return {
|
||||||
|
content: includeBase64 ? [{
|
||||||
|
type: 'image' as 'image',
|
||||||
|
data: screenshot.toString('base64'),
|
||||||
|
mimeType: fileType === 'png' ? 'image/png' : 'image/jpeg',
|
||||||
|
}] : []
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
code,
|
||||||
|
action,
|
||||||
|
captureSnapshot: true,
|
||||||
|
waitForNetwork: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
export default [
|
||||||
|
screenshot,
|
||||||
|
];
|
||||||
@@ -16,18 +16,18 @@
|
|||||||
|
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
|
||||||
import { defineTool } from './tool';
|
import { defineTool } from './tool.js';
|
||||||
import * as javascript from '../javascript';
|
import * as javascript from '../javascript.js';
|
||||||
import { outputFile } from '../config';
|
import { generateLocator } from './utils.js';
|
||||||
|
|
||||||
import type * as playwright from 'playwright';
|
|
||||||
|
|
||||||
const snapshot = defineTool({
|
const snapshot = defineTool({
|
||||||
capability: 'core',
|
capability: 'core',
|
||||||
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: z.object({}),
|
inputSchema: z.object({}),
|
||||||
|
type: 'readOnly',
|
||||||
},
|
},
|
||||||
|
|
||||||
handle: async context => {
|
handle: async context => {
|
||||||
@@ -50,13 +50,15 @@ const click = defineTool({
|
|||||||
capability: 'core',
|
capability: 'core',
|
||||||
schema: {
|
schema: {
|
||||||
name: 'browser_click',
|
name: 'browser_click',
|
||||||
|
title: 'Click',
|
||||||
description: 'Perform click on a web page',
|
description: 'Perform click on a web page',
|
||||||
inputSchema: elementSchema,
|
inputSchema: elementSchema,
|
||||||
|
type: 'destructive',
|
||||||
},
|
},
|
||||||
|
|
||||||
handle: async (context, params) => {
|
handle: async (context, params) => {
|
||||||
const tab = context.currentTabOrDie();
|
const tab = context.currentTabOrDie();
|
||||||
const locator = tab.snapshotOrDie().refLocator(params.ref);
|
const locator = tab.snapshotOrDie().refLocator(params);
|
||||||
|
|
||||||
const code = [
|
const code = [
|
||||||
`// Click ${params.element}`,
|
`// Click ${params.element}`,
|
||||||
@@ -76,6 +78,7 @@ const drag = defineTool({
|
|||||||
capability: 'core',
|
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: z.object({
|
inputSchema: z.object({
|
||||||
startElement: z.string().describe('Human-readable source element description used to obtain the permission to interact with the element'),
|
startElement: z.string().describe('Human-readable source element description used to obtain the permission to interact with the element'),
|
||||||
@@ -83,12 +86,13 @@ const drag = defineTool({
|
|||||||
endElement: z.string().describe('Human-readable target element description used to obtain the permission to interact with the element'),
|
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'),
|
endRef: z.string().describe('Exact target element reference from the page snapshot'),
|
||||||
}),
|
}),
|
||||||
|
type: 'destructive',
|
||||||
},
|
},
|
||||||
|
|
||||||
handle: async (context, params) => {
|
handle: async (context, params) => {
|
||||||
const snapshot = context.currentTabOrDie().snapshotOrDie();
|
const snapshot = context.currentTabOrDie().snapshotOrDie();
|
||||||
const startLocator = snapshot.refLocator(params.startRef);
|
const startLocator = snapshot.refLocator({ ref: params.startRef, element: params.startElement });
|
||||||
const endLocator = snapshot.refLocator(params.endRef);
|
const endLocator = snapshot.refLocator({ ref: params.endRef, element: params.endElement });
|
||||||
|
|
||||||
const code = [
|
const code = [
|
||||||
`// Drag ${params.startElement} to ${params.endElement}`,
|
`// Drag ${params.startElement} to ${params.endElement}`,
|
||||||
@@ -108,13 +112,15 @@ const hover = defineTool({
|
|||||||
capability: 'core',
|
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: elementSchema,
|
inputSchema: elementSchema,
|
||||||
|
type: 'readOnly',
|
||||||
},
|
},
|
||||||
|
|
||||||
handle: async (context, params) => {
|
handle: async (context, params) => {
|
||||||
const snapshot = context.currentTabOrDie().snapshotOrDie();
|
const snapshot = context.currentTabOrDie().snapshotOrDie();
|
||||||
const locator = snapshot.refLocator(params.ref);
|
const locator = snapshot.refLocator(params);
|
||||||
|
|
||||||
const code = [
|
const code = [
|
||||||
`// Hover over ${params.element}`,
|
`// Hover over ${params.element}`,
|
||||||
@@ -140,13 +146,15 @@ const type = defineTool({
|
|||||||
capability: 'core',
|
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: typeSchema,
|
inputSchema: typeSchema,
|
||||||
|
type: 'destructive',
|
||||||
},
|
},
|
||||||
|
|
||||||
handle: async (context, params) => {
|
handle: async (context, params) => {
|
||||||
const snapshot = context.currentTabOrDie().snapshotOrDie();
|
const snapshot = context.currentTabOrDie().snapshotOrDie();
|
||||||
const locator = snapshot.refLocator(params.ref);
|
const locator = snapshot.refLocator(params);
|
||||||
|
|
||||||
const code: string[] = [];
|
const code: string[] = [];
|
||||||
const steps: (() => Promise<void>)[] = [];
|
const steps: (() => Promise<void>)[] = [];
|
||||||
@@ -184,13 +192,15 @@ const selectOption = defineTool({
|
|||||||
capability: 'core',
|
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: selectOptionSchema,
|
inputSchema: selectOptionSchema,
|
||||||
|
type: 'destructive',
|
||||||
},
|
},
|
||||||
|
|
||||||
handle: async (context, params) => {
|
handle: async (context, params) => {
|
||||||
const snapshot = context.currentTabOrDie().snapshotOrDie();
|
const snapshot = context.currentTabOrDie().snapshotOrDie();
|
||||||
const locator = snapshot.refLocator(params.ref);
|
const locator = snapshot.refLocator(params);
|
||||||
|
|
||||||
const code = [
|
const code = [
|
||||||
`// Select options [${params.values.join(', ')}] in ${params.element}`,
|
`// Select options [${params.values.join(', ')}] in ${params.element}`,
|
||||||
@@ -206,69 +216,6 @@ const selectOption = defineTool({
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
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.'),
|
|
||||||
element: z.string().optional().describe('Human-readable element description used to obtain permission to screenshot the element. If not provided, the screenshot will be taken of viewport. If element is provided, ref must be provided too.'),
|
|
||||||
ref: z.string().optional().describe('Exact target element reference from the page snapshot. If not provided, the screenshot will be taken of viewport. If ref is provided, element must be provided too.'),
|
|
||||||
}).refine(data => {
|
|
||||||
return !!data.element === !!data.ref;
|
|
||||||
}, {
|
|
||||||
message: 'Both element and ref must be provided or neither.',
|
|
||||||
path: ['ref', 'element']
|
|
||||||
});
|
|
||||||
|
|
||||||
const screenshot = defineTool({
|
|
||||||
capability: 'core',
|
|
||||||
schema: {
|
|
||||||
name: 'browser_take_screenshot',
|
|
||||||
description: `Take a screenshot of the current page. You can't perform actions based on the screenshot, use browser_snapshot for actions.`,
|
|
||||||
inputSchema: screenshotSchema,
|
|
||||||
},
|
|
||||||
|
|
||||||
handle: async (context, params) => {
|
|
||||||
const tab = context.currentTabOrDie();
|
|
||||||
const snapshot = tab.snapshotOrDie();
|
|
||||||
const fileType = params.raw ? 'png' : 'jpeg';
|
|
||||||
const fileName = await outputFile(context.config, `page-${new Date().toISOString()}.${fileType}`);
|
|
||||||
const options: playwright.PageScreenshotOptions = { type: fileType, quality: fileType === 'png' ? undefined : 50, scale: 'css', path: fileName };
|
|
||||||
const isElementScreenshot = params.element && params.ref;
|
|
||||||
|
|
||||||
const code = [
|
|
||||||
`// Screenshot ${isElementScreenshot ? params.element : 'viewport'} and save it as ${fileName}`,
|
|
||||||
];
|
|
||||||
|
|
||||||
const locator = params.ref ? snapshot.refLocator(params.ref) : null;
|
|
||||||
|
|
||||||
if (locator)
|
|
||||||
code.push(`await page.${await generateLocator(locator)}.screenshot(${javascript.formatObject(options)});`);
|
|
||||||
else
|
|
||||||
code.push(`await page.screenshot(${javascript.formatObject(options)});`);
|
|
||||||
|
|
||||||
const includeBase64 = !context.config.tools?.browser_take_screenshot?.omitBase64;
|
|
||||||
const action = async () => {
|
|
||||||
const screenshot = locator ? await locator.screenshot(options) : await tab.page.screenshot(options);
|
|
||||||
return {
|
|
||||||
content: includeBase64 ? [{
|
|
||||||
type: 'image' as 'image',
|
|
||||||
data: screenshot.toString('base64'),
|
|
||||||
mimeType: fileType === 'png' ? 'image/png' : 'image/jpeg',
|
|
||||||
}] : []
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
return {
|
|
||||||
code,
|
|
||||||
action,
|
|
||||||
captureSnapshot: true,
|
|
||||||
waitForNetwork: false,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
export async function generateLocator(locator: playwright.Locator): Promise<string> {
|
|
||||||
return (locator as any)._generateLocatorString();
|
|
||||||
}
|
|
||||||
|
|
||||||
export default [
|
export default [
|
||||||
snapshot,
|
snapshot,
|
||||||
click,
|
click,
|
||||||
@@ -276,5 +223,4 @@ export default [
|
|||||||
hover,
|
hover,
|
||||||
type,
|
type,
|
||||||
selectOption,
|
selectOption,
|
||||||
screenshot,
|
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -15,15 +15,17 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
import { defineTool, type ToolFactory } from './tool';
|
import { defineTool, type ToolFactory } from './tool.js';
|
||||||
|
|
||||||
const listTabs = defineTool({
|
const listTabs = defineTool({
|
||||||
capability: 'tabs',
|
capability: 'tabs',
|
||||||
|
|
||||||
schema: {
|
schema: {
|
||||||
name: 'browser_tab_list',
|
name: 'browser_tab_list',
|
||||||
|
title: 'List tabs',
|
||||||
description: 'List browser tabs',
|
description: 'List browser tabs',
|
||||||
inputSchema: z.object({}),
|
inputSchema: z.object({}),
|
||||||
|
type: 'readOnly',
|
||||||
},
|
},
|
||||||
|
|
||||||
handle: async context => {
|
handle: async context => {
|
||||||
@@ -47,10 +49,12 @@ const selectTab: ToolFactory = captureSnapshot => defineTool({
|
|||||||
|
|
||||||
schema: {
|
schema: {
|
||||||
name: 'browser_tab_select',
|
name: 'browser_tab_select',
|
||||||
|
title: 'Select a tab',
|
||||||
description: 'Select a tab by index',
|
description: 'Select a tab by index',
|
||||||
inputSchema: z.object({
|
inputSchema: z.object({
|
||||||
index: z.number().describe('The index of the tab to select'),
|
index: z.number().describe('The index of the tab to select'),
|
||||||
}),
|
}),
|
||||||
|
type: 'readOnly',
|
||||||
},
|
},
|
||||||
|
|
||||||
handle: async (context, params) => {
|
handle: async (context, params) => {
|
||||||
@@ -72,10 +76,12 @@ const newTab: ToolFactory = captureSnapshot => defineTool({
|
|||||||
|
|
||||||
schema: {
|
schema: {
|
||||||
name: 'browser_tab_new',
|
name: 'browser_tab_new',
|
||||||
|
title: 'Open a new tab',
|
||||||
description: 'Open a new tab',
|
description: 'Open a new tab',
|
||||||
inputSchema: z.object({
|
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.'),
|
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) => {
|
handle: async (context, params) => {
|
||||||
@@ -99,10 +105,12 @@ const closeTab: ToolFactory = captureSnapshot => defineTool({
|
|||||||
|
|
||||||
schema: {
|
schema: {
|
||||||
name: 'browser_tab_close',
|
name: 'browser_tab_close',
|
||||||
|
title: 'Close a tab',
|
||||||
description: 'Close a tab',
|
description: 'Close a tab',
|
||||||
inputSchema: z.object({
|
inputSchema: z.object({
|
||||||
index: z.number().optional().describe('The index of the tab to close. Closes current tab if not provided.'),
|
index: z.number().optional().describe('The index of the tab to close. Closes current tab if not provided.'),
|
||||||
}),
|
}),
|
||||||
|
type: 'destructive',
|
||||||
},
|
},
|
||||||
|
|
||||||
handle: async (context, params) => {
|
handle: async (context, params) => {
|
||||||
|
|||||||
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,16 +14,18 @@
|
|||||||
* 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 { z } from 'zod';
|
import type { z } from 'zod';
|
||||||
import type { Context } from '../context';
|
import type { Context } from '../context.js';
|
||||||
import type * as playwright from 'playwright';
|
import type * as playwright from 'playwright';
|
||||||
import type { ToolCapability } from '../../config';
|
import type { ToolCapability } from '../../config.js';
|
||||||
|
|
||||||
export type ToolSchema<Input extends InputType> = {
|
export type ToolSchema<Input extends InputType> = {
|
||||||
name: string;
|
name: string;
|
||||||
|
title: string;
|
||||||
description: string;
|
description: string;
|
||||||
inputSchema: Input;
|
inputSchema: Input;
|
||||||
|
type: 'readOnly' | 'destructive';
|
||||||
};
|
};
|
||||||
|
|
||||||
type InputType = z.Schema;
|
type InputType = z.Schema;
|
||||||
|
|||||||
@@ -15,9 +15,10 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import type * as playwright from 'playwright';
|
import type * as playwright from 'playwright';
|
||||||
import type { Context } from '../context';
|
import type { Context } from '../context.js';
|
||||||
|
import type { Tab } from '../tab.js';
|
||||||
|
|
||||||
export async function waitForCompletion<R>(context: Context, 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 = () => {};
|
||||||
@@ -36,9 +37,7 @@ export async function waitForCompletion<R>(context: Context, page: playwright.Pa
|
|||||||
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 = () => {
|
||||||
@@ -46,15 +45,15 @@ export async function waitForCompletion<R>(context: Context, page: playwright.Pa
|
|||||||
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);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -71,5 +70,17 @@ export async function waitForCompletion<R>(context: Context, page: playwright.Pa
|
|||||||
}
|
}
|
||||||
|
|
||||||
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> {
|
||||||
|
return (locator as any)._generateLocatorString();
|
||||||
|
}
|
||||||
|
|
||||||
|
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 });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,9 +15,9 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
import { defineTool } from './tool';
|
import { defineTool } from './tool.js';
|
||||||
|
|
||||||
import * as javascript from '../javascript';
|
import * as javascript from '../javascript.js';
|
||||||
|
|
||||||
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'),
|
||||||
@@ -27,8 +27,10 @@ const screenshot = defineTool({
|
|||||||
capability: 'core',
|
capability: 'core',
|
||||||
schema: {
|
schema: {
|
||||||
name: 'browser_screen_capture',
|
name: 'browser_screen_capture',
|
||||||
|
title: 'Take a screenshot',
|
||||||
description: 'Take a screenshot of the current page',
|
description: 'Take a screenshot of the current page',
|
||||||
inputSchema: z.object({}),
|
inputSchema: z.object({}),
|
||||||
|
type: 'readOnly',
|
||||||
},
|
},
|
||||||
|
|
||||||
handle: async context => {
|
handle: async context => {
|
||||||
@@ -59,11 +61,13 @@ const moveMouse = defineTool({
|
|||||||
capability: 'core',
|
capability: 'core',
|
||||||
schema: {
|
schema: {
|
||||||
name: 'browser_screen_move_mouse',
|
name: 'browser_screen_move_mouse',
|
||||||
|
title: 'Move mouse',
|
||||||
description: 'Move mouse to a given position',
|
description: 'Move mouse to a given position',
|
||||||
inputSchema: elementSchema.extend({
|
inputSchema: elementSchema.extend({
|
||||||
x: z.number().describe('X coordinate'),
|
x: z.number().describe('X coordinate'),
|
||||||
y: z.number().describe('Y coordinate'),
|
y: z.number().describe('Y coordinate'),
|
||||||
}),
|
}),
|
||||||
|
type: 'readOnly',
|
||||||
},
|
},
|
||||||
|
|
||||||
handle: async (context, params) => {
|
handle: async (context, params) => {
|
||||||
@@ -86,11 +90,13 @@ const click = defineTool({
|
|||||||
capability: 'core',
|
capability: 'core',
|
||||||
schema: {
|
schema: {
|
||||||
name: 'browser_screen_click',
|
name: 'browser_screen_click',
|
||||||
|
title: 'Click',
|
||||||
description: 'Click left mouse button',
|
description: 'Click left mouse button',
|
||||||
inputSchema: elementSchema.extend({
|
inputSchema: elementSchema.extend({
|
||||||
x: z.number().describe('X coordinate'),
|
x: z.number().describe('X coordinate'),
|
||||||
y: z.number().describe('Y coordinate'),
|
y: z.number().describe('Y coordinate'),
|
||||||
}),
|
}),
|
||||||
|
type: 'destructive',
|
||||||
},
|
},
|
||||||
|
|
||||||
handle: async (context, params) => {
|
handle: async (context, params) => {
|
||||||
@@ -117,9 +123,9 @@ const click = defineTool({
|
|||||||
|
|
||||||
const drag = defineTool({
|
const drag = defineTool({
|
||||||
capability: 'core',
|
capability: 'core',
|
||||||
|
|
||||||
schema: {
|
schema: {
|
||||||
name: 'browser_screen_drag',
|
name: 'browser_screen_drag',
|
||||||
|
title: 'Drag mouse',
|
||||||
description: 'Drag left mouse button',
|
description: 'Drag left mouse button',
|
||||||
inputSchema: elementSchema.extend({
|
inputSchema: elementSchema.extend({
|
||||||
startX: z.number().describe('Start X coordinate'),
|
startX: z.number().describe('Start X coordinate'),
|
||||||
@@ -127,6 +133,7 @@ const drag = defineTool({
|
|||||||
endX: z.number().describe('End X coordinate'),
|
endX: z.number().describe('End X coordinate'),
|
||||||
endY: z.number().describe('End Y coordinate'),
|
endY: z.number().describe('End Y coordinate'),
|
||||||
}),
|
}),
|
||||||
|
type: 'destructive',
|
||||||
},
|
},
|
||||||
|
|
||||||
handle: async (context, params) => {
|
handle: async (context, params) => {
|
||||||
@@ -158,14 +165,15 @@ const drag = defineTool({
|
|||||||
|
|
||||||
const type = defineTool({
|
const type = defineTool({
|
||||||
capability: 'core',
|
capability: 'core',
|
||||||
|
|
||||||
schema: {
|
schema: {
|
||||||
name: 'browser_screen_type',
|
name: 'browser_screen_type',
|
||||||
|
title: 'Type text',
|
||||||
description: 'Type text',
|
description: 'Type text',
|
||||||
inputSchema: z.object({
|
inputSchema: z.object({
|
||||||
text: z.string().describe('Text to type into the element'),
|
text: z.string().describe('Text to type into the element'),
|
||||||
submit: z.boolean().optional().describe('Whether to submit entered text (press Enter after)'),
|
submit: z.boolean().optional().describe('Whether to submit entered text (press Enter after)'),
|
||||||
}),
|
}),
|
||||||
|
type: 'destructive',
|
||||||
},
|
},
|
||||||
|
|
||||||
handle: async (context, params) => {
|
handle: async (context, params) => {
|
||||||
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),
|
||||||
|
];
|
||||||
@@ -18,17 +18,22 @@ import http from 'node:http';
|
|||||||
import assert from 'node:assert';
|
import assert from 'node:assert';
|
||||||
import crypto from 'node:crypto';
|
import crypto from 'node:crypto';
|
||||||
|
|
||||||
import { ServerList } from './server';
|
|
||||||
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
|
||||||
import { SSEServerTransport } from '@modelcontextprotocol/sdk/server/sse.js';
|
import { SSEServerTransport } from '@modelcontextprotocol/sdk/server/sse.js';
|
||||||
import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
|
import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
|
||||||
|
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
||||||
|
|
||||||
export async function startStdioTransport(serverList: ServerList) {
|
import { createConnection } from './connection.js';
|
||||||
const server = await serverList.create();
|
|
||||||
await server.connect(new StdioServerTransport());
|
import type { Connection } from './connection.js';
|
||||||
|
import type { FullConfig } from './config.js';
|
||||||
|
|
||||||
|
export async function startStdioTransport(config: FullConfig, connectionList: Connection[]) {
|
||||||
|
const connection = await createConnection(config);
|
||||||
|
await connection.connect(new StdioServerTransport());
|
||||||
|
connectionList.push(connection);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleSSE(req: http.IncomingMessage, res: http.ServerResponse, url: URL, serverList: ServerList, sessions: Map<string, SSEServerTransport>) {
|
async function handleSSE(config: FullConfig, req: http.IncomingMessage, res: http.ServerResponse, url: URL, sessions: Map<string, SSEServerTransport>, connectionList: Connection[]) {
|
||||||
if (req.method === 'POST') {
|
if (req.method === 'POST') {
|
||||||
const sessionId = url.searchParams.get('sessionId');
|
const sessionId = url.searchParams.get('sessionId');
|
||||||
if (!sessionId) {
|
if (!sessionId) {
|
||||||
@@ -46,19 +51,24 @@ async function handleSSE(req: http.IncomingMessage, res: http.ServerResponse, ur
|
|||||||
} else if (req.method === 'GET') {
|
} else if (req.method === 'GET') {
|
||||||
const transport = new SSEServerTransport('/sse', res);
|
const transport = new SSEServerTransport('/sse', res);
|
||||||
sessions.set(transport.sessionId, transport);
|
sessions.set(transport.sessionId, transport);
|
||||||
const server = await serverList.create();
|
const connection = await createConnection(config);
|
||||||
|
await connection.connect(transport);
|
||||||
|
connectionList.push(connection);
|
||||||
res.on('close', () => {
|
res.on('close', () => {
|
||||||
sessions.delete(transport.sessionId);
|
sessions.delete(transport.sessionId);
|
||||||
serverList.close(server).catch(e => console.error(e));
|
connection.close().catch(e => {
|
||||||
|
// eslint-disable-next-line no-console
|
||||||
|
console.error(e);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
return await server.connect(transport);
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
res.statusCode = 405;
|
res.statusCode = 405;
|
||||||
res.end('Method not allowed');
|
res.end('Method not allowed');
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleStreamable(req: http.IncomingMessage, res: http.ServerResponse, serverList: ServerList, sessions: Map<string, StreamableHTTPServerTransport>) {
|
async function handleStreamable(config: FullConfig, req: http.IncomingMessage, res: http.ServerResponse, sessions: Map<string, StreamableHTTPServerTransport>, connectionList: Connection[]) {
|
||||||
const sessionId = req.headers['mcp-session-id'] as string | undefined;
|
const sessionId = req.headers['mcp-session-id'] as string | undefined;
|
||||||
if (sessionId) {
|
if (sessionId) {
|
||||||
const transport = sessions.get(sessionId);
|
const transport = sessions.get(sessionId);
|
||||||
@@ -81,24 +91,28 @@ async function handleStreamable(req: http.IncomingMessage, res: http.ServerRespo
|
|||||||
if (transport.sessionId)
|
if (transport.sessionId)
|
||||||
sessions.delete(transport.sessionId);
|
sessions.delete(transport.sessionId);
|
||||||
};
|
};
|
||||||
const server = await serverList.create();
|
const connection = await createConnection(config);
|
||||||
await server.connect(transport);
|
connectionList.push(connection);
|
||||||
return await transport.handleRequest(req, res);
|
await Promise.all([
|
||||||
|
connection.connect(transport),
|
||||||
|
transport.handleRequest(req, res),
|
||||||
|
]);
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
res.statusCode = 400;
|
res.statusCode = 400;
|
||||||
res.end('Invalid request');
|
res.end('Invalid request');
|
||||||
}
|
}
|
||||||
|
|
||||||
export function startHttpTransport(port: number, hostname: string | undefined, serverList: ServerList) {
|
export function startHttpTransport(config: FullConfig, port: number, hostname: string | undefined, connectionList: Connection[]) {
|
||||||
const sseSessions = new Map<string, SSEServerTransport>();
|
const sseSessions = new Map<string, SSEServerTransport>();
|
||||||
const streamableSessions = new Map<string, StreamableHTTPServerTransport>();
|
const streamableSessions = new Map<string, StreamableHTTPServerTransport>();
|
||||||
const httpServer = http.createServer(async (req, res) => {
|
const httpServer = http.createServer(async (req, res) => {
|
||||||
const url = new URL(`http://localhost${req.url}`);
|
const url = new URL(`http://localhost${req.url}`);
|
||||||
if (url.pathname.startsWith('/mcp'))
|
if (url.pathname.startsWith('/mcp'))
|
||||||
await handleStreamable(req, res, serverList, streamableSessions);
|
await handleStreamable(config, req, res, streamableSessions, connectionList);
|
||||||
else
|
else
|
||||||
await handleSSE(req, res, url, serverList, sseSessions);
|
await handleSSE(config, req, res, url, sseSessions, connectionList);
|
||||||
});
|
});
|
||||||
httpServer.listen(port, hostname, () => {
|
httpServer.listen(port, hostname, () => {
|
||||||
const address = httpServer.address();
|
const address = httpServer.address();
|
||||||
@@ -113,15 +127,19 @@ export function startHttpTransport(port: number, hostname: string | undefined, s
|
|||||||
resolvedHost = 'localhost';
|
resolvedHost = 'localhost';
|
||||||
url = `http://${resolvedHost}:${resolvedPort}`;
|
url = `http://${resolvedHost}:${resolvedPort}`;
|
||||||
}
|
}
|
||||||
console.log(`Listening on ${url}`);
|
const message = [
|
||||||
console.log('Put this in your client config:');
|
`Listening on ${url}`,
|
||||||
console.log(JSON.stringify({
|
'Put this in your client config:',
|
||||||
'mcpServers': {
|
JSON.stringify({
|
||||||
'playwright': {
|
'mcpServers': {
|
||||||
'url': `${url}/sse`
|
'playwright': {
|
||||||
|
'url': `${url}/sse`
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}, undefined, 2),
|
||||||
}, undefined, 2));
|
'If your client supports streamable HTTP, you can use the /mcp endpoint instead.',
|
||||||
console.log('If your client supports streamable HTTP, you can use the /mcp endpoint instead.');
|
].join('\n');
|
||||||
|
// eslint-disable-next-line no-console
|
||||||
|
console.error(message);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,7 +14,7 @@
|
|||||||
* limitations under the License.
|
* limitations under the License.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { test, expect } from './fixtures';
|
import { test, expect } from './fixtures.js';
|
||||||
|
|
||||||
test('test snapshot tool list', async ({ client }) => {
|
test('test snapshot tool list', async ({ client }) => {
|
||||||
const { tools } = await client.listTools();
|
const { tools } = await client.listTools();
|
||||||
@@ -23,6 +23,7 @@ test('test snapshot tool list', async ({ client }) => {
|
|||||||
'browser_console_messages',
|
'browser_console_messages',
|
||||||
'browser_drag',
|
'browser_drag',
|
||||||
'browser_file_upload',
|
'browser_file_upload',
|
||||||
|
'browser_generate_playwright_test',
|
||||||
'browser_handle_dialog',
|
'browser_handle_dialog',
|
||||||
'browser_hover',
|
'browser_hover',
|
||||||
'browser_select_option',
|
'browser_select_option',
|
||||||
@@ -42,7 +43,7 @@ test('test snapshot tool list', async ({ client }) => {
|
|||||||
'browser_tab_new',
|
'browser_tab_new',
|
||||||
'browser_tab_select',
|
'browser_tab_select',
|
||||||
'browser_take_screenshot',
|
'browser_take_screenshot',
|
||||||
'browser_wait',
|
'browser_wait_for',
|
||||||
]));
|
]));
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -52,6 +53,7 @@ test('test vision tool list', async ({ visionClient }) => {
|
|||||||
'browser_close',
|
'browser_close',
|
||||||
'browser_console_messages',
|
'browser_console_messages',
|
||||||
'browser_file_upload',
|
'browser_file_upload',
|
||||||
|
'browser_generate_playwright_test',
|
||||||
'browser_handle_dialog',
|
'browser_handle_dialog',
|
||||||
'browser_install',
|
'browser_install',
|
||||||
'browser_navigate_back',
|
'browser_navigate_back',
|
||||||
@@ -70,7 +72,7 @@ test('test vision tool list', async ({ visionClient }) => {
|
|||||||
'browser_tab_list',
|
'browser_tab_list',
|
||||||
'browser_tab_new',
|
'browser_tab_new',
|
||||||
'browser_tab_select',
|
'browser_tab_select',
|
||||||
'browser_wait',
|
'browser_wait_for',
|
||||||
]));
|
]));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -14,20 +14,23 @@
|
|||||||
* limitations under the License.
|
* limitations under the License.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { test, expect } from './fixtures';
|
import { test, expect } from './fixtures.js';
|
||||||
|
|
||||||
test('cdp server', async ({ cdpEndpoint, startClient }) => {
|
test('cdp server', async ({ cdpServer, startClient, server }) => {
|
||||||
const client = await startClient({ args: [`--cdp-endpoint=${cdpEndpoint}`] });
|
await cdpServer.start();
|
||||||
|
const client = await startClient({ args: [`--cdp-endpoint=${cdpServer.endpoint}`] });
|
||||||
expect(await client.callTool({
|
expect(await client.callTool({
|
||||||
name: 'browser_navigate',
|
name: 'browser_navigate',
|
||||||
arguments: {
|
arguments: { url: server.HELLO_WORLD },
|
||||||
url: 'data:text/html,<html><title>Title</title><body>Hello, world!</body></html>',
|
})).toContainTextContent(`- generic [ref=e1]: Hello, world!`);
|
||||||
},
|
|
||||||
})).toContainTextContent(`- text: Hello, world!`);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test('cdp server reuse tab', async ({ cdpEndpoint, startClient }) => {
|
test('cdp server reuse tab', async ({ cdpServer, startClient, server }) => {
|
||||||
const client = await startClient({ args: [`--cdp-endpoint=${cdpEndpoint}`] });
|
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({
|
expect(await client.callTool({
|
||||||
name: 'browser_click',
|
name: 'browser_click',
|
||||||
@@ -39,18 +42,36 @@ test('cdp server reuse tab', async ({ cdpEndpoint, startClient }) => {
|
|||||||
|
|
||||||
expect(await client.callTool({
|
expect(await client.callTool({
|
||||||
name: 'browser_snapshot',
|
name: 'browser_snapshot',
|
||||||
arguments: {},
|
|
||||||
})).toHaveTextContent(`
|
})).toHaveTextContent(`
|
||||||
- Ran Playwright code:
|
- Ran Playwright code:
|
||||||
\`\`\`js
|
\`\`\`js
|
||||||
// <internal code to capture accessibility snapshot>
|
// <internal code to capture accessibility snapshot>
|
||||||
\`\`\`
|
\`\`\`
|
||||||
|
|
||||||
- Page URL: data:text/html,hello world
|
- Page URL: ${server.HELLO_WORLD}
|
||||||
- Page Title:
|
- Page Title: Title
|
||||||
- Page Snapshot
|
- Page Snapshot
|
||||||
\`\`\`yaml
|
\`\`\`yaml
|
||||||
- text: hello world
|
- generic [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 [ref=e1]: Hello, world!`);
|
||||||
|
});
|
||||||
|
|||||||
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 }, 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 }, 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`);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -14,19 +14,28 @@
|
|||||||
* limitations under the License.
|
* limitations under the License.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { test, expect } from './fixtures';
|
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');
|
||||||
|
|
||||||
test('browser_console_messages', async ({ client }) => {
|
|
||||||
await client.callTool({
|
await client.callTool({
|
||||||
name: 'browser_navigate',
|
name: 'browser_navigate',
|
||||||
arguments: {
|
arguments: {
|
||||||
url: 'data:text/html,<html><script>console.log("Hello, world!");console.error("Error"); </script></html>',
|
url: server.PREFIX,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const resource = await client.callTool({
|
const resource = await client.callTool({
|
||||||
name: 'browser_console_messages',
|
name: 'browser_console_messages',
|
||||||
arguments: {},
|
|
||||||
});
|
});
|
||||||
expect(resource).toHaveTextContent([
|
expect(resource).toHaveTextContent([
|
||||||
'[LOG] Hello, world!',
|
'[LOG] Hello, world!',
|
||||||
|
|||||||
@@ -14,44 +14,45 @@
|
|||||||
* limitations under the License.
|
* limitations under the License.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { test, expect } from './fixtures';
|
import { test, expect } from './fixtures.js';
|
||||||
|
|
||||||
test('browser_navigate', async ({ client }) => {
|
test('browser_navigate', async ({ client, server }) => {
|
||||||
expect(await client.callTool({
|
expect(await client.callTool({
|
||||||
name: 'browser_navigate',
|
name: 'browser_navigate',
|
||||||
arguments: {
|
arguments: { url: server.HELLO_WORLD },
|
||||||
url: 'data:text/html,<html><title>Title</title><body>Hello, world!</body></html>',
|
|
||||||
},
|
|
||||||
})).toHaveTextContent(`
|
})).toHaveTextContent(`
|
||||||
- Ran Playwright code:
|
- Ran Playwright code:
|
||||||
\`\`\`js
|
\`\`\`js
|
||||||
// Navigate to data:text/html,<html><title>Title</title><body>Hello, world!</body></html>
|
// Navigate to ${server.HELLO_WORLD}
|
||||||
await page.goto('data:text/html,<html><title>Title</title><body>Hello, world!</body></html>');
|
await page.goto('${server.HELLO_WORLD}');
|
||||||
\`\`\`
|
\`\`\`
|
||||||
|
|
||||||
- Page URL: data:text/html,<html><title>Title</title><body>Hello, world!</body></html>
|
- Page URL: ${server.HELLO_WORLD}
|
||||||
- Page Title: Title
|
- Page Title: Title
|
||||||
- Page Snapshot
|
- Page Snapshot
|
||||||
\`\`\`yaml
|
\`\`\`yaml
|
||||||
- text: Hello, world!
|
- generic [ref=e1]: Hello, world!
|
||||||
\`\`\`
|
\`\`\`
|
||||||
`
|
`
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('browser_click', async ({ client }) => {
|
test('browser_click', async ({ client, server }) => {
|
||||||
|
server.setContent('/', `
|
||||||
|
<title>Title</title>
|
||||||
|
<button>Submit</button>
|
||||||
|
`, 'text/html');
|
||||||
|
|
||||||
await client.callTool({
|
await client.callTool({
|
||||||
name: 'browser_navigate',
|
name: 'browser_navigate',
|
||||||
arguments: {
|
arguments: { url: server.PREFIX },
|
||||||
url: 'data:text/html,<html><title>Title</title><button>Submit</button></html>',
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(await client.callTool({
|
expect(await client.callTool({
|
||||||
name: 'browser_click',
|
name: 'browser_click',
|
||||||
arguments: {
|
arguments: {
|
||||||
element: 'Submit button',
|
element: 'Submit button',
|
||||||
ref: 's1e3',
|
ref: 'e2',
|
||||||
},
|
},
|
||||||
})).toHaveTextContent(`
|
})).toHaveTextContent(`
|
||||||
- Ran Playwright code:
|
- Ran Playwright code:
|
||||||
@@ -60,28 +61,34 @@ test('browser_click', async ({ client }) => {
|
|||||||
await page.getByRole('button', { name: 'Submit' }).click();
|
await page.getByRole('button', { name: 'Submit' }).click();
|
||||||
\`\`\`
|
\`\`\`
|
||||||
|
|
||||||
- Page URL: data:text/html,<html><title>Title</title><button>Submit</button></html>
|
- Page URL: ${server.PREFIX}
|
||||||
- Page Title: Title
|
- Page Title: Title
|
||||||
- Page Snapshot
|
- Page Snapshot
|
||||||
\`\`\`yaml
|
\`\`\`yaml
|
||||||
- button "Submit" [ref=s2e3]
|
- button "Submit" [ref=e2]
|
||||||
\`\`\`
|
\`\`\`
|
||||||
`);
|
`);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('browser_select_option', async ({ client }) => {
|
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({
|
await client.callTool({
|
||||||
name: 'browser_navigate',
|
name: 'browser_navigate',
|
||||||
arguments: {
|
arguments: { url: server.PREFIX },
|
||||||
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({
|
expect(await client.callTool({
|
||||||
name: 'browser_select_option',
|
name: 'browser_select_option',
|
||||||
arguments: {
|
arguments: {
|
||||||
element: 'Select',
|
element: 'Select',
|
||||||
ref: 's1e3',
|
ref: 'e2',
|
||||||
values: ['bar'],
|
values: ['bar'],
|
||||||
},
|
},
|
||||||
})).toHaveTextContent(`
|
})).toHaveTextContent(`
|
||||||
@@ -91,30 +98,37 @@ test('browser_select_option', async ({ client }) => {
|
|||||||
await page.getByRole('combobox').selectOption(['bar']);
|
await page.getByRole('combobox').selectOption(['bar']);
|
||||||
\`\`\`
|
\`\`\`
|
||||||
|
|
||||||
- Page URL: data:text/html,<html><title>Title</title><select><option value="foo">Foo</option><option value="bar">Bar</option></select></html>
|
- Page URL: ${server.PREFIX}
|
||||||
- Page Title: Title
|
- Page Title: Title
|
||||||
- Page Snapshot
|
- Page Snapshot
|
||||||
\`\`\`yaml
|
\`\`\`yaml
|
||||||
- combobox [ref=s2e3]:
|
- combobox [ref=e2]:
|
||||||
- option "Foo"
|
- option "Foo"
|
||||||
- option "Bar" [selected]
|
- option "Bar" [selected]
|
||||||
\`\`\`
|
\`\`\`
|
||||||
`);
|
`);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('browser_select_option (multiple)', async ({ client }) => {
|
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({
|
await client.callTool({
|
||||||
name: 'browser_navigate',
|
name: 'browser_navigate',
|
||||||
arguments: {
|
arguments: { url: server.PREFIX },
|
||||||
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({
|
expect(await client.callTool({
|
||||||
name: 'browser_select_option',
|
name: 'browser_select_option',
|
||||||
arguments: {
|
arguments: {
|
||||||
element: 'Select',
|
element: 'Select',
|
||||||
ref: 's1e3',
|
ref: 'e2',
|
||||||
values: ['bar', 'baz'],
|
values: ['bar', 'baz'],
|
||||||
},
|
},
|
||||||
})).toHaveTextContent(`
|
})).toHaveTextContent(`
|
||||||
@@ -124,52 +138,62 @@ test('browser_select_option (multiple)', async ({ client }) => {
|
|||||||
await page.getByRole('listbox').selectOption(['bar', 'baz']);
|
await page.getByRole('listbox').selectOption(['bar', 'baz']);
|
||||||
\`\`\`
|
\`\`\`
|
||||||
|
|
||||||
- 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 URL: ${server.PREFIX}
|
||||||
- Page Title: Title
|
- Page Title: Title
|
||||||
- Page Snapshot
|
- Page Snapshot
|
||||||
\`\`\`yaml
|
\`\`\`yaml
|
||||||
- listbox [ref=s2e3]:
|
- listbox [ref=e2]:
|
||||||
- option "Foo" [ref=s2e4]
|
- option "Foo" [ref=e3]
|
||||||
- option "Bar" [selected] [ref=s2e5]
|
- option "Bar" [selected] [ref=e4]
|
||||||
- option "Baz" [selected] [ref=s2e6]
|
- option "Baz" [selected] [ref=e5]
|
||||||
\`\`\`
|
\`\`\`
|
||||||
`);
|
`);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('browser_type', async ({ client }) => {
|
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({
|
await client.callTool({
|
||||||
name: 'browser_navigate',
|
name: 'browser_navigate',
|
||||||
arguments: {
|
arguments: {
|
||||||
url: `data:text/html,<input type='keypress' onkeypress="console.log('Key pressed:', event.key, ', Text:', event.target.value)"></input>`,
|
url: server.PREFIX,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
await client.callTool({
|
await client.callTool({
|
||||||
name: 'browser_type',
|
name: 'browser_type',
|
||||||
arguments: {
|
arguments: {
|
||||||
element: 'textbox',
|
element: 'textbox',
|
||||||
ref: 's1e3',
|
ref: 'e2',
|
||||||
text: 'Hi!',
|
text: 'Hi!',
|
||||||
submit: true,
|
submit: true,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
expect(await client.callTool({
|
expect(await client.callTool({
|
||||||
name: 'browser_console_messages',
|
name: 'browser_console_messages',
|
||||||
arguments: {},
|
|
||||||
})).toHaveTextContent('[LOG] Key pressed: Enter , Text: Hi!');
|
})).toHaveTextContent('[LOG] Key pressed: Enter , Text: Hi!');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('browser_type (slowly)', async ({ client }) => {
|
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({
|
await client.callTool({
|
||||||
name: 'browser_navigate',
|
name: 'browser_navigate',
|
||||||
arguments: {
|
arguments: {
|
||||||
url: `data:text/html,<input type='text' onkeydown="console.log('Key pressed:', event.key, 'Text:', event.target.value)"></input>`,
|
url: server.PREFIX,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
await client.callTool({
|
await client.callTool({
|
||||||
name: 'browser_type',
|
name: 'browser_type',
|
||||||
arguments: {
|
arguments: {
|
||||||
element: 'textbox',
|
element: 'textbox',
|
||||||
ref: 's1e3',
|
ref: 'e2',
|
||||||
text: 'Hi!',
|
text: 'Hi!',
|
||||||
submit: true,
|
submit: true,
|
||||||
slowly: true,
|
slowly: true,
|
||||||
@@ -177,7 +201,6 @@ test('browser_type (slowly)', async ({ client }) => {
|
|||||||
});
|
});
|
||||||
expect(await client.callTool({
|
expect(await client.callTool({
|
||||||
name: 'browser_console_messages',
|
name: 'browser_console_messages',
|
||||||
arguments: {},
|
|
||||||
})).toHaveTextContent([
|
})).toHaveTextContent([
|
||||||
'[LOG] Key pressed: H Text: ',
|
'[LOG] Key pressed: H Text: ',
|
||||||
'[LOG] Key pressed: i Text: H',
|
'[LOG] Key pressed: i Text: H',
|
||||||
@@ -186,12 +209,18 @@ test('browser_type (slowly)', async ({ client }) => {
|
|||||||
].join('\n'));
|
].join('\n'));
|
||||||
});
|
});
|
||||||
|
|
||||||
test('browser_resize', async ({ client }) => {
|
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({
|
await client.callTool({
|
||||||
name: 'browser_navigate',
|
name: 'browser_navigate',
|
||||||
arguments: {
|
arguments: { url: server.PREFIX },
|
||||||
url: 'data:text/html,<html><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></html>',
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const response = await client.callTool({
|
const response = await client.callTool({
|
||||||
@@ -206,5 +235,5 @@ test('browser_resize', async ({ client }) => {
|
|||||||
// Resize browser window to 390x780
|
// Resize browser window to 390x780
|
||||||
await page.setViewportSize({ width: 390, height: 780 });
|
await page.setViewportSize({ width: 390, height: 780 });
|
||||||
\`\`\``);
|
\`\`\``);
|
||||||
await expect.poll(() => client.callTool({ name: 'browser_snapshot', arguments: {} })).toContainTextContent('Window size: 390x780');
|
await expect.poll(() => client.callTool({ name: 'browser_snapshot' })).toContainTextContent('Window size: 390x780');
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -14,9 +14,9 @@
|
|||||||
* limitations under the License.
|
* limitations under the License.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { test, expect } from './fixtures';
|
import { test, expect } from './fixtures.js';
|
||||||
|
|
||||||
test('browser_take_screenshot (viewport)', async ({ startClient, server }) => {
|
test('--device should work', async ({ startClient, server }) => {
|
||||||
const client = await startClient({
|
const client = await startClient({
|
||||||
args: ['--device', 'iPhone 15'],
|
args: ['--device', 'iPhone 15'],
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -14,24 +14,23 @@
|
|||||||
* limitations under the License.
|
* limitations under the License.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { test, expect } from './fixtures';
|
import { test, expect } from './fixtures.js';
|
||||||
|
|
||||||
// https://github.com/microsoft/playwright/issues/35663
|
// https://github.com/microsoft/playwright/issues/35663
|
||||||
test.skip(({ mcpBrowser, mcpHeadless }) => mcpBrowser === 'webkit' && mcpHeadless);
|
test.skip(({ mcpBrowser, mcpHeadless }) => mcpBrowser === 'webkit' && mcpHeadless);
|
||||||
|
|
||||||
test('alert dialog', async ({ client }) => {
|
test('alert dialog', async ({ client, server }) => {
|
||||||
|
server.setContent('/', `<button onclick="alert('Alert')">Button</button>`, 'text/html');
|
||||||
expect(await client.callTool({
|
expect(await client.callTool({
|
||||||
name: 'browser_navigate',
|
name: 'browser_navigate',
|
||||||
arguments: {
|
arguments: { url: server.PREFIX },
|
||||||
url: 'data:text/html,<html><title>Title</title><button onclick="alert(\'Alert\')">Button</button></html>',
|
})).toContainTextContent('- button "Button" [ref=e2]');
|
||||||
},
|
|
||||||
})).toContainTextContent('- button "Button" [ref=s1e3]');
|
|
||||||
|
|
||||||
expect(await client.callTool({
|
expect(await client.callTool({
|
||||||
name: 'browser_click',
|
name: 'browser_click',
|
||||||
arguments: {
|
arguments: {
|
||||||
element: 'Button',
|
element: 'Button',
|
||||||
ref: 's1e3',
|
ref: 'e2',
|
||||||
},
|
},
|
||||||
})).toHaveTextContent(`- Ran Playwright code:
|
})).toHaveTextContent(`- Ran Playwright code:
|
||||||
\`\`\`js
|
\`\`\`js
|
||||||
@@ -55,29 +54,35 @@ await page.getByRole('button', { name: 'Button' }).click();
|
|||||||
// <internal code to handle "alert" dialog>
|
// <internal code to handle "alert" dialog>
|
||||||
\`\`\`
|
\`\`\`
|
||||||
|
|
||||||
- Page URL: data:text/html,<html><title>Title</title><button onclick="alert('Alert')">Button</button></html>
|
- Page URL: ${server.PREFIX}
|
||||||
- Page Title: Title
|
- Page Title:
|
||||||
- Page Snapshot
|
- Page Snapshot
|
||||||
\`\`\`yaml
|
\`\`\`yaml
|
||||||
- button "Button" [ref=s2e3]
|
- button "Button" [ref=e2]
|
||||||
\`\`\`
|
\`\`\`
|
||||||
`);
|
`);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('two alert dialogs', async ({ client }) => {
|
test('two alert dialogs', async ({ client, server }) => {
|
||||||
test.fixme(true, 'Race between the dialog and ariaSnapshot');
|
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({
|
expect(await client.callTool({
|
||||||
name: 'browser_navigate',
|
name: 'browser_navigate',
|
||||||
arguments: {
|
arguments: { url: server.PREFIX },
|
||||||
url: 'data:text/html,<html><title>Title</title><button onclick="alert(\'Alert 1\');alert(\'Alert 2\');">Button</button></html>',
|
})).toContainTextContent('- button "Button" [ref=e2]');
|
||||||
},
|
|
||||||
})).toContainTextContent('- button "Button" [ref=s1e3]');
|
|
||||||
|
|
||||||
expect(await client.callTool({
|
expect(await client.callTool({
|
||||||
name: 'browser_click',
|
name: 'browser_click',
|
||||||
arguments: {
|
arguments: {
|
||||||
element: 'Button',
|
element: 'Button',
|
||||||
ref: 's1e3',
|
ref: 'e2',
|
||||||
},
|
},
|
||||||
})).toHaveTextContent(`- Ran Playwright code:
|
})).toHaveTextContent(`- Ran Playwright code:
|
||||||
\`\`\`js
|
\`\`\`js
|
||||||
@@ -98,19 +103,24 @@ await page.getByRole('button', { name: 'Button' }).click();
|
|||||||
expect(result).not.toContainTextContent('### Modal state');
|
expect(result).not.toContainTextContent('### Modal state');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('confirm dialog (true)', async ({ client }) => {
|
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({
|
expect(await client.callTool({
|
||||||
name: 'browser_navigate',
|
name: 'browser_navigate',
|
||||||
arguments: {
|
arguments: { url: server.PREFIX },
|
||||||
url: 'data:text/html,<html><title>Title</title><button onclick="document.body.textContent = confirm(\'Confirm\')">Button</button></html>',
|
})).toContainTextContent('- button "Button" [ref=e2]');
|
||||||
},
|
|
||||||
})).toContainTextContent('- button "Button" [ref=s1e3]');
|
|
||||||
|
|
||||||
expect(await client.callTool({
|
expect(await client.callTool({
|
||||||
name: 'browser_click',
|
name: 'browser_click',
|
||||||
arguments: {
|
arguments: {
|
||||||
element: 'Button',
|
element: 'Button',
|
||||||
ref: 's1e3',
|
ref: 'e2',
|
||||||
},
|
},
|
||||||
})).toContainTextContent(`### Modal state
|
})).toContainTextContent(`### Modal state
|
||||||
- ["confirm" dialog with message "Confirm"]: can be handled by the "browser_handle_dialog" tool`);
|
- ["confirm" dialog with message "Confirm"]: can be handled by the "browser_handle_dialog" tool`);
|
||||||
@@ -126,23 +136,28 @@ test('confirm dialog (true)', async ({ client }) => {
|
|||||||
expect(result).toContainTextContent('// <internal code to handle "confirm" dialog>');
|
expect(result).toContainTextContent('// <internal code to handle "confirm" dialog>');
|
||||||
expect(result).toContainTextContent(`- Page Snapshot
|
expect(result).toContainTextContent(`- Page Snapshot
|
||||||
\`\`\`yaml
|
\`\`\`yaml
|
||||||
- text: "true"
|
- generic [ref=e1]: "true"
|
||||||
\`\`\``);
|
\`\`\``);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('confirm dialog (false)', async ({ client }) => {
|
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({
|
expect(await client.callTool({
|
||||||
name: 'browser_navigate',
|
name: 'browser_navigate',
|
||||||
arguments: {
|
arguments: { url: server.PREFIX },
|
||||||
url: 'data:text/html,<html><title>Title</title><button onclick="document.body.textContent = confirm(\'Confirm\')">Button</button></html>',
|
})).toContainTextContent('- button "Button" [ref=e2]');
|
||||||
},
|
|
||||||
})).toContainTextContent('- button "Button" [ref=s1e3]');
|
|
||||||
|
|
||||||
expect(await client.callTool({
|
expect(await client.callTool({
|
||||||
name: 'browser_click',
|
name: 'browser_click',
|
||||||
arguments: {
|
arguments: {
|
||||||
element: 'Button',
|
element: 'Button',
|
||||||
ref: 's1e3',
|
ref: 'e2',
|
||||||
},
|
},
|
||||||
})).toContainTextContent(`### Modal state
|
})).toContainTextContent(`### Modal state
|
||||||
- ["confirm" dialog with message "Confirm"]: can be handled by the "browser_handle_dialog" tool`);
|
- ["confirm" dialog with message "Confirm"]: can be handled by the "browser_handle_dialog" tool`);
|
||||||
@@ -156,23 +171,28 @@ test('confirm dialog (false)', async ({ client }) => {
|
|||||||
|
|
||||||
expect(result).toContainTextContent(`- Page Snapshot
|
expect(result).toContainTextContent(`- Page Snapshot
|
||||||
\`\`\`yaml
|
\`\`\`yaml
|
||||||
- text: "false"
|
- generic [ref=e1]: "false"
|
||||||
\`\`\``);
|
\`\`\``);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('prompt dialog', async ({ client }) => {
|
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({
|
expect(await client.callTool({
|
||||||
name: 'browser_navigate',
|
name: 'browser_navigate',
|
||||||
arguments: {
|
arguments: { url: server.PREFIX },
|
||||||
url: 'data:text/html,<html><title>Title</title><button onclick="document.body.textContent = prompt(\'Prompt\')">Button</button></html>',
|
})).toContainTextContent('- button "Button" [ref=e2]');
|
||||||
},
|
|
||||||
})).toContainTextContent('- button "Button" [ref=s1e3]');
|
|
||||||
|
|
||||||
expect(await client.callTool({
|
expect(await client.callTool({
|
||||||
name: 'browser_click',
|
name: 'browser_click',
|
||||||
arguments: {
|
arguments: {
|
||||||
element: 'Button',
|
element: 'Button',
|
||||||
ref: 's1e3',
|
ref: 'e2',
|
||||||
},
|
},
|
||||||
})).toContainTextContent(`### Modal state
|
})).toContainTextContent(`### Modal state
|
||||||
- ["prompt" dialog with message "Prompt"]: can be handled by the "browser_handle_dialog" tool`);
|
- ["prompt" dialog with message "Prompt"]: can be handled by the "browser_handle_dialog" tool`);
|
||||||
@@ -187,6 +207,6 @@ test('prompt dialog', async ({ client }) => {
|
|||||||
|
|
||||||
expect(result).toContainTextContent(`- Page Snapshot
|
expect(result).toContainTextContent(`- Page Snapshot
|
||||||
\`\`\`yaml
|
\`\`\`yaml
|
||||||
- text: Answer
|
- generic [ref=e1]: Answer
|
||||||
\`\`\``);
|
\`\`\``);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -14,20 +14,23 @@
|
|||||||
* limitations under the License.
|
* limitations under the License.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { test, expect } from './fixtures';
|
import { test, expect } from './fixtures.js';
|
||||||
import fs from 'fs/promises';
|
import fs from 'fs/promises';
|
||||||
|
|
||||||
test('browser_file_upload', async ({ client }) => {
|
test('browser_file_upload', async ({ client, server }, testInfo) => {
|
||||||
|
server.setContent('/', `
|
||||||
|
<input type="file" />
|
||||||
|
<button>Button</button>
|
||||||
|
`, 'text/html');
|
||||||
|
|
||||||
expect(await client.callTool({
|
expect(await client.callTool({
|
||||||
name: 'browser_navigate',
|
name: 'browser_navigate',
|
||||||
arguments: {
|
arguments: { url: server.PREFIX },
|
||||||
url: 'data:text/html,<html><title>Title</title><input type="file" /><button>Button</button></html>',
|
|
||||||
},
|
|
||||||
})).toContainTextContent(`
|
})).toContainTextContent(`
|
||||||
\`\`\`yaml
|
\`\`\`yaml
|
||||||
- generic [ref=s1e2]:
|
- generic [ref=e1]:
|
||||||
- button "Choose File" [ref=s1e3]
|
- button "Choose File" [ref=e2]
|
||||||
- button "Button" [ref=s1e4]
|
- button "Button" [ref=e3]
|
||||||
\`\`\``);
|
\`\`\``);
|
||||||
|
|
||||||
{
|
{
|
||||||
@@ -45,12 +48,12 @@ The tool "browser_file_upload" can only be used when there is related modal stat
|
|||||||
name: 'browser_click',
|
name: 'browser_click',
|
||||||
arguments: {
|
arguments: {
|
||||||
element: 'Textbox',
|
element: 'Textbox',
|
||||||
ref: 's1e3',
|
ref: 'e2',
|
||||||
},
|
},
|
||||||
})).toContainTextContent(`### Modal state
|
})).toContainTextContent(`### Modal state
|
||||||
- [File chooser]: can be handled by the "browser_file_upload" tool`);
|
- [File chooser]: can be handled by the "browser_file_upload" tool`);
|
||||||
|
|
||||||
const filePath = test.info().outputPath('test.txt');
|
const filePath = testInfo.outputPath('test.txt');
|
||||||
await fs.writeFile(filePath, 'Hello, world!');
|
await fs.writeFile(filePath, 'Hello, world!');
|
||||||
|
|
||||||
{
|
{
|
||||||
@@ -64,9 +67,9 @@ The tool "browser_file_upload" can only be used when there is related modal stat
|
|||||||
expect(response).not.toContainTextContent('### Modal state');
|
expect(response).not.toContainTextContent('### Modal state');
|
||||||
expect(response).toContainTextContent(`
|
expect(response).toContainTextContent(`
|
||||||
\`\`\`yaml
|
\`\`\`yaml
|
||||||
- generic [ref=s3e2]:
|
- generic [ref=e1]:
|
||||||
- button "Choose File" [ref=s3e3]
|
- button "Choose File" [ref=e2]
|
||||||
- button "Button" [ref=s3e4]
|
- button "Button" [ref=e3]
|
||||||
\`\`\``);
|
\`\`\``);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -75,7 +78,7 @@ The tool "browser_file_upload" can only be used when there is related modal stat
|
|||||||
name: 'browser_click',
|
name: 'browser_click',
|
||||||
arguments: {
|
arguments: {
|
||||||
element: 'Textbox',
|
element: 'Textbox',
|
||||||
ref: 's3e3',
|
ref: 'e2',
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -87,7 +90,7 @@ The tool "browser_file_upload" can only be used when there is related modal stat
|
|||||||
name: 'browser_click',
|
name: 'browser_click',
|
||||||
arguments: {
|
arguments: {
|
||||||
element: 'Button',
|
element: 'Button',
|
||||||
ref: 's4e4',
|
ref: 'e3',
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -96,3 +99,49 @@ The tool "browser_file_upload" can only be used when there is related modal stat
|
|||||||
- [File chooser]: can be handled by the "browser_file_upload" tool`);
|
- [File chooser]: can be handled by the "browser_file_upload" tool`);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('clicking on download link emits download', async ({ startClient, server }, 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 }, 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');
|
||||||
|
});
|
||||||
|
|||||||
@@ -15,34 +15,44 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import fs from 'fs';
|
import fs from 'fs';
|
||||||
|
import url from 'url';
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
import { chromium } from 'playwright';
|
import { chromium } from 'playwright';
|
||||||
|
|
||||||
import { test as baseTest, expect as baseExpect } from '@playwright/test';
|
import { test as baseTest, expect as baseExpect } from '@playwright/test';
|
||||||
import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js';
|
import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js';
|
||||||
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
|
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
|
||||||
import { spawn } from 'child_process';
|
import { TestServer } from './testserver/index.ts';
|
||||||
import { TestServer } from './testserver';
|
|
||||||
|
|
||||||
import type { Config } from '../config';
|
import type { Config } from '../config';
|
||||||
|
import type { BrowserContext } from 'playwright';
|
||||||
|
|
||||||
|
export type TestOptions = {
|
||||||
|
mcpBrowser: string | undefined;
|
||||||
|
mcpMode: 'docker' | undefined;
|
||||||
|
};
|
||||||
|
|
||||||
|
type CDPServer = {
|
||||||
|
endpoint: string;
|
||||||
|
start: () => Promise<BrowserContext>;
|
||||||
|
};
|
||||||
|
|
||||||
type TestFixtures = {
|
type TestFixtures = {
|
||||||
client: Client;
|
client: Client;
|
||||||
visionClient: Client;
|
visionClient: Client;
|
||||||
startClient: (options?: { args?: string[], config?: Config }) => Promise<Client>;
|
startClient: (options?: { clientName?: string, args?: string[], config?: Config }) => Promise<Client>;
|
||||||
wsEndpoint: string;
|
wsEndpoint: string;
|
||||||
cdpEndpoint: string;
|
cdpServer: CDPServer;
|
||||||
server: TestServer;
|
server: TestServer;
|
||||||
httpsServer: TestServer;
|
httpsServer: TestServer;
|
||||||
|
mcpHeadless: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
type WorkerFixtures = {
|
type WorkerFixtures = {
|
||||||
mcpHeadless: boolean;
|
|
||||||
mcpBrowser: string | undefined;
|
|
||||||
_workerServers: { server: TestServer, httpsServer: TestServer };
|
_workerServers: { server: TestServer, httpsServer: TestServer };
|
||||||
};
|
};
|
||||||
|
|
||||||
export const test = baseTest.extend<TestFixtures, WorkerFixtures>({
|
export const test = baseTest.extend<TestFixtures & TestOptions, WorkerFixtures>({
|
||||||
|
|
||||||
client: async ({ startClient }, use) => {
|
client: async ({ startClient }, use) => {
|
||||||
await use(await startClient());
|
await use(await startClient());
|
||||||
@@ -52,12 +62,15 @@ export const test = baseTest.extend<TestFixtures, WorkerFixtures>({
|
|||||||
await use(await startClient({ args: ['--vision'] }));
|
await use(await startClient({ args: ['--vision'] }));
|
||||||
},
|
},
|
||||||
|
|
||||||
startClient: async ({ mcpHeadless, mcpBrowser }, use, testInfo) => {
|
startClient: async ({ mcpHeadless, mcpBrowser, mcpMode }, use, testInfo) => {
|
||||||
const userDataDir = testInfo.outputPath('user-data-dir');
|
const userDataDir = testInfo.outputPath('user-data-dir');
|
||||||
let client: StdioClientTransport | undefined;
|
const configDir = path.dirname(test.info().config.configFile!);
|
||||||
|
let client: Client | undefined;
|
||||||
|
|
||||||
await use(async options => {
|
await use(async options => {
|
||||||
const args = ['--user-data-dir', userDataDir];
|
const args = ['--user-data-dir', path.relative(configDir, userDataDir)];
|
||||||
|
if (process.env.CI && process.platform === 'linux')
|
||||||
|
args.push('--no-sandbox');
|
||||||
if (mcpHeadless)
|
if (mcpHeadless)
|
||||||
args.push('--headless');
|
args.push('--headless');
|
||||||
if (mcpBrowser)
|
if (mcpBrowser)
|
||||||
@@ -67,13 +80,11 @@ export const test = baseTest.extend<TestFixtures, WorkerFixtures>({
|
|||||||
if (options?.config) {
|
if (options?.config) {
|
||||||
const configFile = testInfo.outputPath('config.json');
|
const configFile = testInfo.outputPath('config.json');
|
||||||
await fs.promises.writeFile(configFile, JSON.stringify(options.config, null, 2));
|
await fs.promises.writeFile(configFile, JSON.stringify(options.config, null, 2));
|
||||||
args.push(`--config=${configFile}`);
|
args.push(`--config=${path.relative(configDir, configFile)}`);
|
||||||
}
|
}
|
||||||
const transport = new StdioClientTransport({
|
|
||||||
command: 'node',
|
client = new Client({ name: options?.clientName ?? 'test', version: '1.0.0' });
|
||||||
args: [path.join(__dirname, '../cli.js'), ...args],
|
const transport = createTransport(args, mcpMode);
|
||||||
});
|
|
||||||
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;
|
||||||
@@ -88,35 +99,34 @@ export const test = baseTest.extend<TestFixtures, WorkerFixtures>({
|
|||||||
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 executablePath = chromium.executablePath();
|
|
||||||
const browserProcess = spawn(executablePath, [
|
let browserContext: BrowserContext | undefined;
|
||||||
`--user-data-dir=${testInfo.outputPath('user-data-dir')}`,
|
const port = 3200 + test.info().parallelIndex;
|
||||||
`--remote-debugging-port=${port}`,
|
await use({
|
||||||
`--no-first-run`,
|
endpoint: `http://localhost:${port}`,
|
||||||
`--no-sandbox`,
|
start: async () => {
|
||||||
`--headless`,
|
browserContext = await chromium.launchPersistentContext(testInfo.outputPath('cdp-user-data-dir'), {
|
||||||
'--use-mock-keychain',
|
channel: mcpBrowser,
|
||||||
`data:text/html,hello world`,
|
headless: true,
|
||||||
], {
|
args: [
|
||||||
stdio: 'pipe',
|
`--remote-debugging-port=${port}`,
|
||||||
|
],
|
||||||
|
});
|
||||||
|
return browserContext;
|
||||||
|
}
|
||||||
});
|
});
|
||||||
await new Promise<void>(resolve => {
|
await browserContext?.close();
|
||||||
browserProcess.stderr.on('data', data => {
|
|
||||||
if (data.toString().includes('DevTools listening on '))
|
|
||||||
resolve();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
await use(`http://localhost:${port}`);
|
|
||||||
browserProcess.kill();
|
|
||||||
},
|
},
|
||||||
|
|
||||||
mcpHeadless: [async ({ headless }, use) => {
|
mcpHeadless: async ({ headless }, use) => {
|
||||||
await use(headless);
|
await use(headless);
|
||||||
}, { scope: 'worker' }],
|
},
|
||||||
|
|
||||||
mcpBrowser: ['chrome', { option: true, scope: 'worker' }],
|
mcpBrowser: ['chrome', { option: true }],
|
||||||
|
|
||||||
|
mcpMode: [undefined, { option: true }],
|
||||||
|
|
||||||
_workerServers: [async ({}, use, workerInfo) => {
|
_workerServers: [async ({}, use, workerInfo) => {
|
||||||
const port = 8907 + workerInfo.workerIndex * 4;
|
const port = 8907 + workerInfo.workerIndex * 4;
|
||||||
@@ -144,6 +154,24 @@ export const test = baseTest.extend<TestFixtures, WorkerFixtures>({
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
function createTransport(args: string[], mcpMode: TestOptions['mcpMode']) {
|
||||||
|
// 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`];
|
||||||
|
return new StdioClientTransport({
|
||||||
|
command: 'docker',
|
||||||
|
args: [...dockerArgs, 'playwright-mcp-dev:latest', ...args],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return new StdioClientTransport({
|
||||||
|
command: 'node',
|
||||||
|
args: [path.join(path.dirname(__filename), '../cli.js'), ...args],
|
||||||
|
cwd: path.join(path.dirname(__filename), '..'),
|
||||||
|
env: process.env as Record<string, string>,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
type Response = Awaited<ReturnType<Client['callTool']>>;
|
type Response = Awaited<ReturnType<Client['callTool']>>;
|
||||||
|
|
||||||
export const expect = baseExpect.extend({
|
export const expect = baseExpect.extend({
|
||||||
|
|||||||
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`);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -14,7 +14,7 @@
|
|||||||
* limitations under the License.
|
* limitations under the License.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { test, expect } from './fixtures';
|
import { test, expect } from './fixtures.js';
|
||||||
|
|
||||||
test('stitched aria frames', async ({ client }) => {
|
test('stitched aria frames', async ({ client }) => {
|
||||||
expect(await client.callTool({
|
expect(await client.callTool({
|
||||||
@@ -24,21 +24,21 @@ test('stitched aria frames', async ({ client }) => {
|
|||||||
},
|
},
|
||||||
})).toContainTextContent(`
|
})).toContainTextContent(`
|
||||||
\`\`\`yaml
|
\`\`\`yaml
|
||||||
- generic [ref=s1e2]:
|
- generic [ref=e1]:
|
||||||
- heading "Hello" [level=1] [ref=s1e3]
|
- heading "Hello" [level=1] [ref=e2]
|
||||||
- iframe [ref=s1e4]:
|
- iframe [ref=e3]:
|
||||||
- generic [ref=f1s1e2]:
|
- generic [ref=f1e1]:
|
||||||
- button "World" [ref=f1s1e3]
|
- button "World" [ref=f1e2]
|
||||||
- main [ref=f1s1e4]:
|
- main [ref=f1e3]:
|
||||||
- iframe [ref=f1s1e5]:
|
- iframe [ref=f1e4]:
|
||||||
- paragraph [ref=f2s1e3]: Nested
|
- paragraph [ref=f2e2]: Nested
|
||||||
\`\`\``);
|
\`\`\``);
|
||||||
|
|
||||||
expect(await client.callTool({
|
expect(await client.callTool({
|
||||||
name: 'browser_click',
|
name: 'browser_click',
|
||||||
arguments: {
|
arguments: {
|
||||||
element: 'World',
|
element: 'World',
|
||||||
ref: 'f1s1e3',
|
ref: 'f1e2',
|
||||||
},
|
},
|
||||||
})).toContainTextContent(`// Click World`);
|
})).toContainTextContent(`// Click World`);
|
||||||
});
|
});
|
||||||
|
|||||||
24
tests/install.spec.ts
Normal file
24
tests/install.spec.ts
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
/**
|
||||||
|
* 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_install', async ({ client, mcpBrowser }) => {
|
||||||
|
test.skip(mcpBrowser !== 'chromium', 'Test only chromium');
|
||||||
|
expect(await client.callTool({
|
||||||
|
name: 'browser_install',
|
||||||
|
})).toContainTextContent(`No open pages available.`);
|
||||||
|
});
|
||||||
@@ -14,36 +14,122 @@
|
|||||||
* limitations under the License.
|
* limitations under the License.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { test, expect } from './fixtures';
|
import fs from 'fs';
|
||||||
|
|
||||||
test('test reopen browser', async ({ client }) => {
|
import { test, expect } from './fixtures.js';
|
||||||
|
|
||||||
|
test('test reopen browser', async ({ client, server }) => {
|
||||||
await client.callTool({
|
await client.callTool({
|
||||||
name: 'browser_navigate',
|
name: 'browser_navigate',
|
||||||
arguments: {
|
arguments: { url: server.HELLO_WORLD },
|
||||||
url: 'data:text/html,<html><title>Title</title><body>Hello, world!</body></html>',
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(await client.callTool({
|
expect(await client.callTool({
|
||||||
name: 'browser_close',
|
name: 'browser_close',
|
||||||
arguments: {},
|
|
||||||
})).toContainTextContent('No open pages available');
|
})).toContainTextContent('No open pages available');
|
||||||
|
|
||||||
expect(await client.callTool({
|
expect(await client.callTool({
|
||||||
name: 'browser_navigate',
|
name: 'browser_navigate',
|
||||||
arguments: {
|
arguments: { url: server.HELLO_WORLD },
|
||||||
url: 'data:text/html,<html><title>Title</title><body>Hello, world!</body></html>',
|
})).toContainTextContent(`- generic [ref=e1]: Hello, world!`);
|
||||||
},
|
|
||||||
})).toContainTextContent(`- text: Hello, world!`);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test('executable path', async ({ startClient }) => {
|
test('executable path', async ({ startClient, server }) => {
|
||||||
const client = await startClient({ args: [`--executable-path=bogus`] });
|
const client = await startClient({ args: [`--executable-path=bogus`] });
|
||||||
const response = await client.callTool({
|
const response = await client.callTool({
|
||||||
name: 'browser_navigate',
|
name: 'browser_navigate',
|
||||||
arguments: {
|
arguments: { url: server.HELLO_WORLD },
|
||||||
url: 'data:text/html,<html><title>Title</title><body>Hello, world!</body></html>',
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
expect(response).toContainTextContent(`executable doesn't exist`);
|
expect(response).toContainTextContent(`executable doesn't exist`);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('persistent context', async ({ startClient, server }) => {
|
||||||
|
server.setContent('/', `
|
||||||
|
<body>
|
||||||
|
</body>
|
||||||
|
<script>
|
||||||
|
document.body.textContent = localStorage.getItem('test') ? 'Storage: YES' : 'Storage: NO';
|
||||||
|
localStorage.setItem('test', 'test');
|
||||||
|
</script>
|
||||||
|
`, 'text/html');
|
||||||
|
|
||||||
|
const client = await startClient();
|
||||||
|
const response = await client.callTool({
|
||||||
|
name: 'browser_navigate',
|
||||||
|
arguments: { url: server.PREFIX },
|
||||||
|
});
|
||||||
|
expect(response).toContainTextContent(`Storage: NO`);
|
||||||
|
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 3000));
|
||||||
|
|
||||||
|
await client.callTool({
|
||||||
|
name: 'browser_close',
|
||||||
|
});
|
||||||
|
|
||||||
|
const client2 = await startClient();
|
||||||
|
const response2 = await client2.callTool({
|
||||||
|
name: 'browser_navigate',
|
||||||
|
arguments: { url: server.PREFIX },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response2).toContainTextContent(`Storage: YES`);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('isolated context', async ({ startClient, server }) => {
|
||||||
|
server.setContent('/', `
|
||||||
|
<body>
|
||||||
|
</body>
|
||||||
|
<script>
|
||||||
|
document.body.textContent = localStorage.getItem('test') ? 'Storage: YES' : 'Storage: NO';
|
||||||
|
localStorage.setItem('test', 'test');
|
||||||
|
</script>
|
||||||
|
`, 'text/html');
|
||||||
|
|
||||||
|
const client = await startClient({ args: [`--isolated`] });
|
||||||
|
const response = await client.callTool({
|
||||||
|
name: 'browser_navigate',
|
||||||
|
arguments: { url: server.PREFIX },
|
||||||
|
});
|
||||||
|
expect(response).toContainTextContent(`Storage: NO`);
|
||||||
|
|
||||||
|
await client.callTool({
|
||||||
|
name: 'browser_close',
|
||||||
|
});
|
||||||
|
|
||||||
|
const client2 = await startClient({ args: [`--isolated`] });
|
||||||
|
const response2 = await client2.callTool({
|
||||||
|
name: 'browser_navigate',
|
||||||
|
arguments: { url: server.PREFIX },
|
||||||
|
});
|
||||||
|
expect(response2).toContainTextContent(`Storage: NO`);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('isolated context with storage state', async ({ startClient, server }, 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');
|
||||||
|
});
|
||||||
@@ -14,18 +14,14 @@
|
|||||||
* limitations under the License.
|
* limitations under the License.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { test, expect } from './fixtures';
|
import { test, expect } from './fixtures.js';
|
||||||
|
|
||||||
test('browser_network_requests', async ({ client, server }) => {
|
test('browser_network_requests', async ({ client, server }) => {
|
||||||
server.route('/', (req, res) => {
|
server.setContent('/', `
|
||||||
res.writeHead(200, { 'Content-Type': 'text/html' });
|
<button onclick="fetch('/json')">Click me</button>
|
||||||
res.end(`<button onclick="fetch('/json')">Click me</button>`);
|
`, 'text/html');
|
||||||
});
|
|
||||||
|
|
||||||
server.route('/json', (req, res) => {
|
server.setContent('/json', JSON.stringify({ name: 'John Doe' }), 'application/json');
|
||||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
||||||
res.end(JSON.stringify({ name: 'John Doe' }));
|
|
||||||
});
|
|
||||||
|
|
||||||
await client.callTool({
|
await client.callTool({
|
||||||
name: 'browser_navigate',
|
name: 'browser_navigate',
|
||||||
@@ -38,12 +34,12 @@ test('browser_network_requests', async ({ client, server }) => {
|
|||||||
name: 'browser_click',
|
name: 'browser_click',
|
||||||
arguments: {
|
arguments: {
|
||||||
element: 'Click me button',
|
element: 'Click me button',
|
||||||
ref: 's1e3',
|
ref: 'e2',
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
await expect.poll(() => client.callTool({
|
await expect.poll(() => client.callTool({
|
||||||
name: 'browser_network_requests',
|
name: 'browser_network_requests',
|
||||||
arguments: {},
|
})).toHaveTextContent(`[GET] ${`${server.PREFIX}`} => [200] OK
|
||||||
})).toHaveTextContent(`[GET] http://localhost:${server.PORT}/json => [200] OK`);
|
[GET] ${`${server.PREFIX}json`} => [200] OK`);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -14,15 +14,15 @@
|
|||||||
* limitations under the License.
|
* limitations under the License.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { test, expect } from './fixtures';
|
import fs from 'fs';
|
||||||
|
|
||||||
test('save as pdf unavailable', async ({ startClient }) => {
|
import { test, expect } from './fixtures.js';
|
||||||
|
|
||||||
|
test('save as pdf unavailable', async ({ startClient, server }) => {
|
||||||
const client = await startClient({ args: ['--caps="no-pdf"'] });
|
const client = await startClient({ args: ['--caps="no-pdf"'] });
|
||||||
await client.callTool({
|
await client.callTool({
|
||||||
name: 'browser_navigate',
|
name: 'browser_navigate',
|
||||||
arguments: {
|
arguments: { url: server.HELLO_WORLD },
|
||||||
url: 'data:text/html,<html><title>Title</title><body>Hello, world!</body></html>',
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(await client.callTool({
|
expect(await client.callTool({
|
||||||
@@ -30,18 +30,54 @@ test('save as pdf unavailable', async ({ startClient }) => {
|
|||||||
})).toHaveTextContent(/Tool \"browser_pdf_save\" not found/);
|
})).toHaveTextContent(/Tool \"browser_pdf_save\" not found/);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('save as pdf', async ({ client, mcpBrowser }) => {
|
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.');
|
test.skip(!!mcpBrowser && !['chromium', 'chrome', 'msedge'].includes(mcpBrowser), 'Save as PDF is only supported in Chromium.');
|
||||||
|
|
||||||
expect(await client.callTool({
|
expect(await client.callTool({
|
||||||
name: 'browser_navigate',
|
name: 'browser_navigate',
|
||||||
arguments: {
|
arguments: { url: server.HELLO_WORLD },
|
||||||
url: 'data:text/html,<html><title>Title</title><body>Hello, world!</body></html>',
|
})).toContainTextContent(`- generic [ref=e1]: Hello, world!`);
|
||||||
},
|
|
||||||
})).toContainTextContent(`- text: Hello, world!`);
|
|
||||||
|
|
||||||
const response = await client.callTool({
|
const response = await client.callTool({
|
||||||
name: 'browser_pdf_save',
|
name: 'browser_pdf_save',
|
||||||
arguments: {},
|
|
||||||
});
|
});
|
||||||
expect(response).toHaveTextContent(/Save page as.*page-[^:]+.pdf/);
|
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 [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');
|
||||||
|
});
|
||||||
@@ -16,19 +16,19 @@
|
|||||||
|
|
||||||
import fs from 'fs';
|
import fs from 'fs';
|
||||||
|
|
||||||
import { test, expect } from './fixtures';
|
import { test, expect } from './fixtures.js';
|
||||||
|
|
||||||
test('browser_take_screenshot (viewport)', async ({ client }) => {
|
test('browser_take_screenshot (viewport)', async ({ startClient, server }, testInfo) => {
|
||||||
|
const client = await startClient({
|
||||||
|
config: { outputDir: testInfo.outputPath('output') },
|
||||||
|
});
|
||||||
expect(await client.callTool({
|
expect(await client.callTool({
|
||||||
name: 'browser_navigate',
|
name: 'browser_navigate',
|
||||||
arguments: {
|
arguments: { url: server.HELLO_WORLD },
|
||||||
url: 'data:text/html,<html><title>Title</title><body>Hello, world!</body></html>',
|
})).toContainTextContent(`Navigate to http://localhost`);
|
||||||
},
|
|
||||||
})).toContainTextContent(`Navigate to data:text/html`);
|
|
||||||
|
|
||||||
expect(await client.callTool({
|
expect(await client.callTool({
|
||||||
name: 'browser_take_screenshot',
|
name: 'browser_take_screenshot',
|
||||||
arguments: {},
|
|
||||||
})).toEqual({
|
})).toEqual({
|
||||||
content: [
|
content: [
|
||||||
{
|
{
|
||||||
@@ -44,19 +44,20 @@ test('browser_take_screenshot (viewport)', async ({ client }) => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
test('browser_take_screenshot (element)', async ({ client }) => {
|
test('browser_take_screenshot (element)', async ({ startClient, server }, testInfo) => {
|
||||||
|
const client = await startClient({
|
||||||
|
config: { outputDir: testInfo.outputPath('output') },
|
||||||
|
});
|
||||||
expect(await client.callTool({
|
expect(await client.callTool({
|
||||||
name: 'browser_navigate',
|
name: 'browser_navigate',
|
||||||
arguments: {
|
arguments: { url: server.HELLO_WORLD },
|
||||||
url: 'data:text/html,<html><title>Title</title><button>Hello, world!</button></html>',
|
})).toContainTextContent(`[ref=e1]`);
|
||||||
},
|
|
||||||
})).toContainTextContent(`[ref=s1e3]`);
|
|
||||||
|
|
||||||
expect(await client.callTool({
|
expect(await client.callTool({
|
||||||
name: 'browser_take_screenshot',
|
name: 'browser_take_screenshot',
|
||||||
arguments: {
|
arguments: {
|
||||||
element: 'hello button',
|
element: 'hello button',
|
||||||
ref: 's1e3',
|
ref: 'e1',
|
||||||
},
|
},
|
||||||
})).toEqual({
|
})).toEqual({
|
||||||
content: [
|
content: [
|
||||||
@@ -66,60 +67,160 @@ test('browser_take_screenshot (element)', async ({ client }) => {
|
|||||||
type: 'image',
|
type: 'image',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
text: expect.stringContaining(`page.getByRole('button', { name: 'Hello, world!' }).screenshot`),
|
text: expect.stringContaining(`page.getByText('Hello, world!').screenshot`),
|
||||||
type: 'text',
|
type: 'text',
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
test('browser_take_screenshot (outputDir)', async ({ startClient }, testInfo) => {
|
test('--output-dir should work', async ({ startClient, server }, testInfo) => {
|
||||||
const outputDir = testInfo.outputPath('output');
|
const outputDir = testInfo.outputPath('output');
|
||||||
const client = await startClient({
|
const client = await startClient({
|
||||||
config: { outputDir },
|
config: { outputDir },
|
||||||
});
|
});
|
||||||
expect(await client.callTool({
|
expect(await client.callTool({
|
||||||
name: 'browser_navigate',
|
name: 'browser_navigate',
|
||||||
arguments: {
|
arguments: { url: server.HELLO_WORLD },
|
||||||
url: 'data:text/html,<html><title>Title</title><body>Hello, world!</body></html>',
|
})).toContainTextContent(`Navigate to http://localhost`);
|
||||||
},
|
|
||||||
})).toContainTextContent(`Navigate to data:text/html`);
|
|
||||||
|
|
||||||
await client.callTool({
|
await client.callTool({
|
||||||
name: 'browser_take_screenshot',
|
name: 'browser_take_screenshot',
|
||||||
arguments: {},
|
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(fs.existsSync(outputDir)).toBeTruthy();
|
expect(fs.existsSync(outputDir)).toBeTruthy();
|
||||||
expect([...fs.readdirSync(outputDir)]).toHaveLength(1);
|
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$/);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('browser_take_screenshot (omitBase64)', async ({ startClient }) => {
|
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({
|
const client = await startClient({
|
||||||
config: {
|
config: {
|
||||||
tools: {
|
outputDir,
|
||||||
browser_take_screenshot: {
|
imageResponses: 'omit',
|
||||||
omitBase64: true,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(await client.callTool({
|
expect(await client.callTool({
|
||||||
name: 'browser_navigate',
|
name: 'browser_navigate',
|
||||||
arguments: {
|
arguments: { url: server.HELLO_WORLD },
|
||||||
url: 'data:text/html,<html><title>Title</title><body>Hello, world!</body></html>',
|
})).toContainTextContent(`Navigate to http://localhost`);
|
||||||
},
|
|
||||||
})).toContainTextContent(`Navigate to data:text/html`);
|
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({
|
await client.callTool({
|
||||||
name: 'browser_take_screenshot',
|
name: 'browser_take_screenshot',
|
||||||
arguments: {},
|
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(await client.callTool({
|
expect(await client.callTool({
|
||||||
name: 'browser_take_screenshot',
|
name: 'browser_take_screenshot',
|
||||||
arguments: {},
|
|
||||||
})).toEqual({
|
})).toEqual({
|
||||||
content: [
|
content: [
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -14,19 +14,31 @@
|
|||||||
* limitations under the License.
|
* limitations under the License.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import url from 'node:url';
|
||||||
|
import http from 'node:http';
|
||||||
import { spawn } from 'node:child_process';
|
import { spawn } from 'node:child_process';
|
||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
import { test as baseTest } from './fixtures';
|
import type { AddressInfo } from 'node:net';
|
||||||
import { expect } from 'playwright/test';
|
import { SSEClientTransport } from '@modelcontextprotocol/sdk/client/sse.js';
|
||||||
|
import { SSEServerTransport } from '@modelcontextprotocol/sdk/server/sse.js';
|
||||||
|
import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js';
|
||||||
|
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
|
||||||
|
|
||||||
|
import { createConnection } from '@playwright/mcp';
|
||||||
|
|
||||||
|
import { test as baseTest, expect } from './fixtures.js';
|
||||||
|
|
||||||
|
// NOTE: Can be removed when we drop Node.js 18 support and changed to import.meta.filename.
|
||||||
|
const __filename = url.fileURLToPath(import.meta.url);
|
||||||
|
|
||||||
const test = baseTest.extend<{ serverEndpoint: string }>({
|
const test = baseTest.extend<{ serverEndpoint: string }>({
|
||||||
serverEndpoint: async ({}, use) => {
|
serverEndpoint: async ({}, use) => {
|
||||||
const cp = spawn('node', [path.join(__dirname, '../cli.js'), '--port', '0'], { stdio: 'pipe' });
|
const cp = spawn('node', [path.join(path.dirname(__filename), '../cli.js'), '--port', '0'], { stdio: 'pipe' });
|
||||||
try {
|
try {
|
||||||
let stdout = '';
|
let stderr = '';
|
||||||
const url = await new Promise<string>(resolve => cp.stdout?.on('data', data => {
|
const url = await new Promise<string>(resolve => cp.stderr?.on('data', data => {
|
||||||
stdout += data.toString();
|
stderr += data.toString();
|
||||||
const match = stdout.match(/Listening on (http:\/\/.*)/);
|
const match = stderr.match(/Listening on (http:\/\/.*)/);
|
||||||
if (match)
|
if (match)
|
||||||
resolve(match[1]);
|
resolve(match[1]);
|
||||||
}));
|
}));
|
||||||
@@ -39,9 +51,6 @@ const test = baseTest.extend<{ serverEndpoint: string }>({
|
|||||||
});
|
});
|
||||||
|
|
||||||
test('sse transport', async ({ serverEndpoint }) => {
|
test('sse transport', async ({ serverEndpoint }) => {
|
||||||
// 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(serverEndpoint));
|
const transport = new SSEClientTransport(new URL(serverEndpoint));
|
||||||
const client = new Client({ name: 'test', version: '1.0.0' });
|
const client = new Client({ name: 'test', version: '1.0.0' });
|
||||||
await client.connect(transport);
|
await client.connect(transport);
|
||||||
@@ -49,12 +58,52 @@ test('sse transport', async ({ serverEndpoint }) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test('streamable http transport', async ({ serverEndpoint }) => {
|
test('streamable http transport', async ({ serverEndpoint }) => {
|
||||||
// need dynamic import b/c of some ESM nonsense
|
|
||||||
const { StreamableHTTPClientTransport } = await import('@modelcontextprotocol/sdk/client/streamableHttp.js');
|
|
||||||
const { Client } = await import('@modelcontextprotocol/sdk/client/index.js');
|
|
||||||
const transport = new StreamableHTTPClientTransport(new URL('/mcp', serverEndpoint));
|
const transport = new StreamableHTTPClientTransport(new URL('/mcp', serverEndpoint));
|
||||||
const client = new Client({ name: 'test', version: '1.0.0' });
|
const client = new Client({ name: 'test', version: '1.0.0' });
|
||||||
await client.connect(transport);
|
await client.connect(transport);
|
||||||
await client.ping();
|
await client.ping();
|
||||||
expect(transport.sessionId, 'has session support').toBeDefined();
|
expect(transport.sessionId, 'has session support').toBeDefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('sse transport via public API', async ({ server }, testInfo) => {
|
||||||
|
const userDataDir = testInfo.outputPath('user-data-dir');
|
||||||
|
const sessions = new Map<string, SSEServerTransport>();
|
||||||
|
const mcpServer = http.createServer(async (req, res) => {
|
||||||
|
if (req.method === 'GET') {
|
||||||
|
const connection = await createConnection({
|
||||||
|
browser: {
|
||||||
|
userDataDir,
|
||||||
|
launchOptions: { headless: true }
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const transport = new SSEServerTransport('/sse', res);
|
||||||
|
sessions.set(transport.sessionId, transport);
|
||||||
|
await connection.connect(transport);
|
||||||
|
} else if (req.method === 'POST') {
|
||||||
|
const url = new URL(`http://localhost${req.url}`);
|
||||||
|
const sessionId = url.searchParams.get('sessionId');
|
||||||
|
if (!sessionId) {
|
||||||
|
res.statusCode = 400;
|
||||||
|
return res.end('Missing sessionId');
|
||||||
|
}
|
||||||
|
const transport = sessions.get(sessionId);
|
||||||
|
if (!transport) {
|
||||||
|
res.statusCode = 404;
|
||||||
|
return res.end('Session not found');
|
||||||
|
}
|
||||||
|
void transport.handlePostMessage(req, res);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
await new Promise<void>(resolve => mcpServer.listen(0, () => resolve()));
|
||||||
|
const serverUrl = `http://localhost:${(mcpServer.address() as AddressInfo).port}/sse`;
|
||||||
|
const transport = new SSEClientTransport(new URL(serverUrl));
|
||||||
|
const client = new Client({ name: 'test', version: '1.0.0' });
|
||||||
|
await client.connect(transport);
|
||||||
|
await client.ping();
|
||||||
|
expect(await client.callTool({
|
||||||
|
name: 'browser_navigate',
|
||||||
|
arguments: { url: server.HELLO_WORLD },
|
||||||
|
})).toContainTextContent(`- generic [ref=e1]: Hello, world!`);
|
||||||
|
await client.close();
|
||||||
|
mcpServer.close();
|
||||||
|
});
|
||||||
|
|||||||
@@ -14,9 +14,7 @@
|
|||||||
* limitations under the License.
|
* limitations under the License.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { chromium } from 'playwright';
|
import { test, expect } from './fixtures.js';
|
||||||
|
|
||||||
import { test, expect } from './fixtures';
|
|
||||||
|
|
||||||
import type { Client } from '@modelcontextprotocol/sdk/client/index.js';
|
import type { Client } from '@modelcontextprotocol/sdk/client/index.js';
|
||||||
|
|
||||||
@@ -32,7 +30,6 @@ async function createTab(client: Client, title: string, body: string) {
|
|||||||
test('list initial tabs', async ({ client }) => {
|
test('list initial tabs', async ({ client }) => {
|
||||||
expect(await client.callTool({
|
expect(await client.callTool({
|
||||||
name: 'browser_tab_list',
|
name: 'browser_tab_list',
|
||||||
arguments: {},
|
|
||||||
})).toHaveTextContent(`### Open tabs
|
})).toHaveTextContent(`### Open tabs
|
||||||
- 1: (current) [] (about:blank)`);
|
- 1: (current) [] (about:blank)`);
|
||||||
});
|
});
|
||||||
@@ -41,7 +38,6 @@ test('list first tab', async ({ client }) => {
|
|||||||
await createTab(client, 'Tab one', 'Body one');
|
await createTab(client, 'Tab one', 'Body one');
|
||||||
expect(await client.callTool({
|
expect(await client.callTool({
|
||||||
name: 'browser_tab_list',
|
name: 'browser_tab_list',
|
||||||
arguments: {},
|
|
||||||
})).toHaveTextContent(`### Open tabs
|
})).toHaveTextContent(`### Open tabs
|
||||||
- 1: [] (about:blank)
|
- 1: [] (about:blank)
|
||||||
- 2: (current) [Tab one] (data:text/html,<title>Tab one</title><body>Body one</body>)`);
|
- 2: (current) [Tab one] (data:text/html,<title>Tab one</title><body>Body one</body>)`);
|
||||||
@@ -63,7 +59,7 @@ test('create new tab', async ({ client }) => {
|
|||||||
- Page Title: Tab one
|
- Page Title: Tab one
|
||||||
- Page Snapshot
|
- Page Snapshot
|
||||||
\`\`\`yaml
|
\`\`\`yaml
|
||||||
- text: Body one
|
- generic [ref=e1]: Body one
|
||||||
\`\`\``);
|
\`\`\``);
|
||||||
|
|
||||||
expect(await createTab(client, 'Tab two', 'Body two')).toHaveTextContent(`
|
expect(await createTab(client, 'Tab two', 'Body two')).toHaveTextContent(`
|
||||||
@@ -82,7 +78,7 @@ test('create new tab', async ({ client }) => {
|
|||||||
- Page Title: Tab two
|
- Page Title: Tab two
|
||||||
- Page Snapshot
|
- Page Snapshot
|
||||||
\`\`\`yaml
|
\`\`\`yaml
|
||||||
- text: Body two
|
- generic [ref=e1]: Body two
|
||||||
\`\`\``);
|
\`\`\``);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -110,7 +106,7 @@ test('select tab', async ({ client }) => {
|
|||||||
- Page Title: Tab one
|
- Page Title: Tab one
|
||||||
- Page Snapshot
|
- Page Snapshot
|
||||||
\`\`\`yaml
|
\`\`\`yaml
|
||||||
- text: Body one
|
- generic [ref=e1]: Body one
|
||||||
\`\`\``);
|
\`\`\``);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -137,21 +133,18 @@ test('close tab', async ({ client }) => {
|
|||||||
- Page Title: Tab one
|
- Page Title: Tab one
|
||||||
- Page Snapshot
|
- Page Snapshot
|
||||||
\`\`\`yaml
|
\`\`\`yaml
|
||||||
- text: Body one
|
- generic [ref=e1]: Body one
|
||||||
\`\`\``);
|
\`\`\``);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('reuse first tab when navigating', async ({ startClient, cdpEndpoint }) => {
|
test('reuse first tab when navigating', async ({ startClient, cdpServer, server }) => {
|
||||||
const browser = await chromium.connectOverCDP(cdpEndpoint);
|
const browserContext = await cdpServer.start();
|
||||||
const [context] = browser.contexts();
|
const pages = browserContext.pages();
|
||||||
const pages = context.pages();
|
|
||||||
|
|
||||||
const client = await startClient({ args: [`--cdp-endpoint=${cdpEndpoint}`] });
|
const client = await startClient({ args: [`--cdp-endpoint=${cdpServer.endpoint}`] });
|
||||||
await client.callTool({
|
await client.callTool({
|
||||||
name: 'browser_navigate',
|
name: 'browser_navigate',
|
||||||
arguments: {
|
arguments: { url: server.HELLO_WORLD },
|
||||||
url: 'data:text/html,<title>Title</title><body>Body</body>',
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(pages.length).toBe(1);
|
expect(pages.length).toBe(1);
|
||||||
|
|||||||
@@ -16,13 +16,18 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import fs from 'fs';
|
import fs from 'fs';
|
||||||
|
import url from 'node:url';
|
||||||
import http from 'http';
|
import http from 'http';
|
||||||
import https from 'https';
|
import https from 'https';
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
|
import debug from 'debug';
|
||||||
|
|
||||||
const fulfillSymbol = Symbol('fulfil callback');
|
const fulfillSymbol = Symbol('fulfil callback');
|
||||||
const rejectSymbol = Symbol('reject callback');
|
const rejectSymbol = Symbol('reject callback');
|
||||||
|
|
||||||
|
// NOTE: Can be removed when we drop Node.js 18 support and changed to import.meta.filename.
|
||||||
|
const __filename = url.fileURLToPath(import.meta.url);
|
||||||
|
|
||||||
export class TestServer {
|
export class TestServer {
|
||||||
private _server: http.Server;
|
private _server: http.Server;
|
||||||
readonly debugServer: any;
|
readonly debugServer: any;
|
||||||
@@ -33,6 +38,7 @@ export class TestServer {
|
|||||||
readonly PORT: number;
|
readonly PORT: number;
|
||||||
readonly PREFIX: string;
|
readonly PREFIX: string;
|
||||||
readonly CROSS_PROCESS_PREFIX: string;
|
readonly CROSS_PROCESS_PREFIX: string;
|
||||||
|
readonly HELLO_WORLD: string;
|
||||||
|
|
||||||
static async create(port: number): Promise<TestServer> {
|
static async create(port: number): Promise<TestServer> {
|
||||||
const server = new TestServer(port);
|
const server = new TestServer(port);
|
||||||
@@ -42,8 +48,8 @@ export class TestServer {
|
|||||||
|
|
||||||
static async createHTTPS(port: number): Promise<TestServer> {
|
static async createHTTPS(port: number): Promise<TestServer> {
|
||||||
const server = new TestServer(port, {
|
const server = new TestServer(port, {
|
||||||
key: await fs.promises.readFile(path.join(__dirname, 'key.pem')),
|
key: await fs.promises.readFile(path.join(path.dirname(__filename), 'key.pem')),
|
||||||
cert: await fs.promises.readFile(path.join(__dirname, 'cert.pem')),
|
cert: await fs.promises.readFile(path.join(path.dirname(__filename), 'cert.pem')),
|
||||||
passphrase: 'aaaa',
|
passphrase: 'aaaa',
|
||||||
});
|
});
|
||||||
await new Promise(x => server._server.once('listening', x));
|
await new Promise(x => server._server.once('listening', x));
|
||||||
@@ -56,14 +62,15 @@ export class TestServer {
|
|||||||
else
|
else
|
||||||
this._server = http.createServer(this._onRequest.bind(this));
|
this._server = http.createServer(this._onRequest.bind(this));
|
||||||
this._server.listen(port);
|
this._server.listen(port);
|
||||||
this.debugServer = require('debug')('pw:testserver');
|
this.debugServer = debug('pw:testserver');
|
||||||
|
|
||||||
const cross_origin = '127.0.0.1';
|
const cross_origin = '127.0.0.1';
|
||||||
const same_origin = 'localhost';
|
const same_origin = 'localhost';
|
||||||
const protocol = sslOptions ? 'https' : 'http';
|
const protocol = sslOptions ? 'https' : 'http';
|
||||||
this.PORT = port;
|
this.PORT = port;
|
||||||
this.PREFIX = `${protocol}://${same_origin}:${port}`;
|
this.PREFIX = `${protocol}://${same_origin}:${port}/`;
|
||||||
this.CROSS_PROCESS_PREFIX = `${protocol}://${cross_origin}:${port}`;
|
this.CROSS_PROCESS_PREFIX = `${protocol}://${cross_origin}:${port}/`;
|
||||||
|
this.HELLO_WORLD = `${this.PREFIX}hello-world`;
|
||||||
}
|
}
|
||||||
|
|
||||||
setCSP(path: string, csp: string) {
|
setCSP(path: string, csp: string) {
|
||||||
@@ -83,6 +90,13 @@ export class TestServer {
|
|||||||
this._routes.set(path, handler);
|
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) {
|
redirect(from: string, to: string) {
|
||||||
this.route(from, (req, res) => {
|
this.route(from, (req, res) => {
|
||||||
const headers = this._extraHeaders.get(req.url!) || {};
|
const headers = this._extraHeaders.get(req.url!) || {};
|
||||||
@@ -115,6 +129,15 @@ export class TestServer {
|
|||||||
for (const subscriber of this._requestSubscribers.values())
|
for (const subscriber of this._requestSubscribers.values())
|
||||||
subscriber[rejectSymbol].call(null, error);
|
subscriber[rejectSymbol].call(null, error);
|
||||||
this._requestSubscribers.clear();
|
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) {
|
_onRequest(request: http.IncomingMessage, response: http.ServerResponse) {
|
||||||
@@ -139,7 +162,11 @@ export class TestServer {
|
|||||||
this._requestSubscribers.delete(path);
|
this._requestSubscribers.delete(path);
|
||||||
}
|
}
|
||||||
const handler = this._routes.get(path);
|
const handler = this._routes.get(path);
|
||||||
if (handler)
|
if (handler) {
|
||||||
handler.call(null, request, response);
|
handler.call(null, request, response);
|
||||||
|
} else {
|
||||||
|
response.writeHead(404);
|
||||||
|
response.end();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
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 }, 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`);
|
||||||
|
});
|
||||||
@@ -14,7 +14,7 @@
|
|||||||
* limitations under the License.
|
* limitations under the License.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { test, expect } from './fixtures';
|
import { test, expect } from './fixtures.js';
|
||||||
|
|
||||||
test('do not falsely advertise user agent as a test driver', async ({ client, server, mcpBrowser }) => {
|
test('do not falsely advertise user agent as a test driver', async ({ client, server, mcpBrowser }) => {
|
||||||
test.skip(mcpBrowser === 'firefox');
|
test.skip(mcpBrowser === 'firefox');
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -16,163 +16,178 @@
|
|||||||
*/
|
*/
|
||||||
// @ts-check
|
// @ts-check
|
||||||
|
|
||||||
const fs = require('node:fs');
|
import fs from 'node:fs'
|
||||||
const path = require('node:path');
|
import path from 'node:path'
|
||||||
const zodToJsonSchema = require('zod-to-json-schema').default;
|
import url from 'node:url'
|
||||||
|
import zodToJsonSchema from 'zod-to-json-schema'
|
||||||
|
|
||||||
const commonTools = require('../lib/tools/common').default;
|
import commonTools from '../lib/tools/common.js';
|
||||||
const consoleTools = require('../lib/tools/console').default;
|
import consoleTools from '../lib/tools/console.js';
|
||||||
const dialogsTools = require('../lib/tools/dialogs').default;
|
import dialogsTools from '../lib/tools/dialogs.js';
|
||||||
const filesTools = require('../lib/tools/files').default;
|
import filesTools from '../lib/tools/files.js';
|
||||||
const installTools = require('../lib/tools/install').default;
|
import installTools from '../lib/tools/install.js';
|
||||||
const keyboardTools = require('../lib/tools/keyboard').default;
|
import keyboardTools from '../lib/tools/keyboard.js';
|
||||||
const navigateTools = require('../lib/tools/navigate').default;
|
import navigateTools from '../lib/tools/navigate.js';
|
||||||
const pdfTools = require('../lib/tools/pdf').default;
|
import networkTools from '../lib/tools/network.js';
|
||||||
const snapshotTools = require('../lib/tools/snapshot').default;
|
import pdfTools from '../lib/tools/pdf.js';
|
||||||
const tabsTools = require('../lib/tools/tabs').default;
|
import snapshotTools from '../lib/tools/snapshot.js';
|
||||||
const screenTools = require('../lib/tools/screen').default;
|
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';
|
||||||
|
|
||||||
// Category definitions for tools
|
|
||||||
const categories = {
|
const categories = {
|
||||||
'Snapshot-based Interactions': [
|
'Interactions': [
|
||||||
...snapshotTools,
|
...snapshotTools,
|
||||||
],
|
...keyboardTools(true),
|
||||||
'Vision-based Interactions': [
|
...waitTools(true),
|
||||||
...screenTools
|
...filesTools(true),
|
||||||
],
|
...dialogsTools(true),
|
||||||
'Tab Management': [
|
|
||||||
...tabsTools(true),
|
|
||||||
],
|
],
|
||||||
'Navigation': [
|
'Navigation': [
|
||||||
...navigateTools(true),
|
...navigateTools(true),
|
||||||
],
|
],
|
||||||
'Keyboard': [
|
'Resources': [
|
||||||
...keyboardTools(true)
|
...screenshotTools,
|
||||||
],
|
...pdfTools,
|
||||||
'Console': [
|
...networkTools,
|
||||||
...consoleTools
|
...consoleTools,
|
||||||
],
|
|
||||||
'Files and Media': [
|
|
||||||
...filesTools(true),
|
|
||||||
...pdfTools
|
|
||||||
],
|
],
|
||||||
'Utilities': [
|
'Utilities': [
|
||||||
...commonTools(true),
|
|
||||||
...installTools,
|
...installTools,
|
||||||
...dialogsTools(true),
|
...commonTools(true),
|
||||||
|
],
|
||||||
|
'Tabs': [
|
||||||
|
...tabsTools(true),
|
||||||
|
],
|
||||||
|
'Testing': [
|
||||||
|
...testTools,
|
||||||
|
],
|
||||||
|
'Vision mode': [
|
||||||
|
...visionTools,
|
||||||
|
...keyboardTools(),
|
||||||
|
...waitTools(false),
|
||||||
|
...filesTools(false),
|
||||||
|
...dialogsTools(false),
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|
||||||
const kStartMarker = `<!--- Generated by ${path.basename(__filename)} -->`;
|
// NOTE: Can be removed when we drop Node.js 18 support and changed to import.meta.filename.
|
||||||
const kEndMarker = `<!--- End of generated section -->`;
|
const __filename = url.fileURLToPath(import.meta.url);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param {ParsedToolSchema} tool
|
* @param {import('../src/tools/tool.js').ToolSchema<any>} tool
|
||||||
* @returns {string}
|
* @returns {string[]}
|
||||||
*/
|
*/
|
||||||
function formatToolForReadme(tool) {
|
function formatToolForReadme(tool) {
|
||||||
const lines = /** @type {string[]} */ ([]);
|
const lines = /** @type {string[]} */ ([]);
|
||||||
lines.push(`<!-- NOTE: This has been generated via ${path.basename(__filename)} -->\n\n`);
|
lines.push(`<!-- NOTE: This has been generated via ${path.basename(__filename)} -->`);
|
||||||
lines.push(`- **${tool.name}**\n`);
|
lines.push(``);
|
||||||
lines.push(` - Description: ${tool.description}\n`);
|
lines.push(`- **${tool.name}**`);
|
||||||
|
lines.push(` - Title: ${tool.title}`);
|
||||||
|
lines.push(` - Description: ${tool.description}`);
|
||||||
|
|
||||||
if (tool.parameters && tool.parameters.length > 0) {
|
const inputSchema = /** @type {any} */ (zodToJsonSchema(tool.inputSchema || {}));
|
||||||
lines.push(` - Parameters:\n`);
|
const requiredParams = inputSchema.required || [];
|
||||||
tool.parameters.forEach(param => {
|
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[]} */ ([]);
|
const meta = /** @type {string[]} */ ([]);
|
||||||
if (param.type)
|
if (param.type)
|
||||||
meta.push(param.type);
|
meta.push(param.type);
|
||||||
if (param.optional)
|
if (optional)
|
||||||
meta.push('optional');
|
meta.push('optional');
|
||||||
lines.push(` - \`${param.name}\` ${meta.length ? `(${meta.join(', ')})` : ''}: ${param.description}\n`);
|
lines.push(` - \`${name}\` ${meta.length ? `(${meta.join(', ')})` : ''}: ${param.description}`);
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
lines.push(` - Parameters: None\n`);
|
lines.push(` - Parameters: None`);
|
||||||
}
|
}
|
||||||
|
lines.push(` - Read-only: **${tool.type === 'readOnly'}**`);
|
||||||
lines.push('\n');
|
lines.push('');
|
||||||
return lines.join('');
|
return lines;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @typedef {{
|
* @param {string} content
|
||||||
* name: any;
|
* @param {string} startMarker
|
||||||
* description: any;
|
* @param {string} endMarker
|
||||||
* parameters: {
|
* @param {string[]} generatedLines
|
||||||
* name: string;
|
* @returns {Promise<string>}
|
||||||
* description: string;
|
|
||||||
* optional: boolean;
|
|
||||||
* type: string;
|
|
||||||
* }[];
|
|
||||||
*}} ParsedToolSchema
|
|
||||||
*/
|
*/
|
||||||
|
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 [
|
||||||
* @param {import('../src/tools/tool').ToolSchema<any>} schema
|
content.slice(0, startMarkerIndex + startMarker.length),
|
||||||
* @returns {ParsedToolSchema}
|
'',
|
||||||
*/
|
generatedLines.join('\n'),
|
||||||
function processToolSchema(schema) {
|
'',
|
||||||
const inputSchema = /** @type {import('zod-to-json-schema').JsonSchema7ObjectType} */ (zodToJsonSchema(schema.inputSchema || {}));
|
content.slice(endMarkerIndex),
|
||||||
if (inputSchema.type !== 'object')
|
].join('\n');
|
||||||
throw new Error(`Tool ${schema.name} input schema is not an object`);
|
|
||||||
|
|
||||||
// In JSON Schema, properties are considered optional unless listed in the required array
|
|
||||||
const requiredParams = inputSchema?.required || [];
|
|
||||||
|
|
||||||
const parameters = Object.entries(inputSchema.properties).map(([name, prop]) => {
|
|
||||||
return {
|
|
||||||
name,
|
|
||||||
description: prop.description || '',
|
|
||||||
optional: !requiredParams.includes(name),
|
|
||||||
type: /** @type {any} */ (prop).type,
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
return {
|
|
||||||
name: schema.name,
|
|
||||||
description: schema.description,
|
|
||||||
parameters
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function updateReadme() {
|
/**
|
||||||
|
* @param {string} content
|
||||||
|
* @returns {Promise<string>}
|
||||||
|
*/
|
||||||
|
async function updateTools(content) {
|
||||||
console.log('Loading tool information from compiled modules...');
|
console.log('Loading tool information from compiled modules...');
|
||||||
|
|
||||||
// Count the tools processed
|
|
||||||
const totalTools = Object.values(categories).flat().length;
|
const totalTools = Object.values(categories).flat().length;
|
||||||
console.log(`Found ${totalTools} tools`);
|
console.log(`Found ${totalTools} tools`);
|
||||||
|
|
||||||
const generatedLines = /** @type {string[]} */ ([]);
|
const generatedLines = /** @type {string[]} */ ([]);
|
||||||
|
|
||||||
for (const [category, categoryTools] of Object.entries(categories)) {
|
for (const [category, categoryTools] of Object.entries(categories)) {
|
||||||
generatedLines.push(`### ${category}\n\n`);
|
generatedLines.push(`<details>\n<summary><b>${category}</b></summary>`);
|
||||||
for (const tool of categoryTools) {
|
generatedLines.push('');
|
||||||
const scheme = processToolSchema(tool.schema);
|
for (const tool of categoryTools)
|
||||||
generatedLines.push(formatToolForReadme(scheme));
|
generatedLines.push(...formatToolForReadme(tool.schema));
|
||||||
}
|
generatedLines.push(`</details>`);
|
||||||
|
generatedLines.push('');
|
||||||
}
|
}
|
||||||
|
|
||||||
const readmePath = path.join(__dirname, '..', 'README.md');
|
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 readmeContent = await fs.promises.readFile(readmePath, 'utf-8');
|
||||||
const startMarker = readmeContent.indexOf(kStartMarker);
|
const withTools = await updateTools(readmeContent);
|
||||||
const endMarker = readmeContent.indexOf(kEndMarker);
|
const withOptions = await updateOptions(withTools);
|
||||||
if (startMarker === -1 || endMarker === -1)
|
await fs.promises.writeFile(readmePath, withOptions, 'utf-8');
|
||||||
throw new Error('Markers for generated section not found in README');
|
|
||||||
|
|
||||||
const newReadmeContent = [
|
|
||||||
readmeContent.slice(0, startMarker),
|
|
||||||
kStartMarker + '\n\n',
|
|
||||||
generatedLines.join(''),
|
|
||||||
kEndMarker,
|
|
||||||
readmeContent.slice(endMarker + kEndMarker.length),
|
|
||||||
].join('');
|
|
||||||
|
|
||||||
// Write updated README
|
|
||||||
await fs.promises.writeFile(readmePath, newReadmeContent, 'utf-8');
|
|
||||||
console.log('README updated successfully');
|
console.log('README updated successfully');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Run the update
|
|
||||||
updateReadme().catch(err => {
|
updateReadme().catch(err => {
|
||||||
console.error('Error updating README:', err);
|
console.error('Error updating README:', err);
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
|
|||||||
Reference in New Issue
Block a user