Compare commits
58 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
675b083db3 | ||
|
|
0b74cdaaf8 | ||
|
|
f31ef598bc | ||
|
|
656779531c | ||
|
|
eec177d3ac | ||
|
|
54ed7c3200 | ||
|
|
3cd74a824a | ||
|
|
177b008328 | ||
|
|
9429463951 | ||
|
|
45f493da6c | ||
|
|
9e5ffd2ccf | ||
|
|
1051ea810a | ||
|
|
f20ae22ec6 | ||
|
|
13cd1b4bd9 | ||
|
|
c318f13895 | ||
|
|
1318e39fac | ||
|
|
c2b7fb29de | ||
|
|
aa6ac51f92 | ||
|
|
fea50e6840 | ||
|
|
746c9fc124 | ||
|
|
ee33097abe | ||
|
|
ab20175826 | ||
|
|
c506027aec | ||
|
|
7be0c8872e | ||
|
|
ce72367208 | ||
|
|
949f956378 | ||
|
|
a1eee8351e | ||
|
|
fea3f26e85 | ||
|
|
dd5b41f1d8 | ||
|
|
05dc5d915b | ||
|
|
65a229c79f | ||
|
|
84664d4b09 | ||
|
|
445170a76b | ||
|
|
c28b480b51 | ||
|
|
65716b60dd | ||
|
|
75f74a54bc | ||
|
|
ef41c626ef | ||
|
|
95ca08fdb7 | ||
|
|
053c2f3d32 | ||
|
|
57b3c14276 | ||
|
|
85c85bd2fb | ||
|
|
09ba7989c3 | ||
|
|
a115c31953 | ||
|
|
b5be37e5e7 | ||
|
|
c2255246a3 | ||
|
|
950d0d1d34 | ||
|
|
cdeba454b5 | ||
|
|
91ae93c167 | ||
|
|
35e6c49d7c | ||
|
|
e95b5b1dd6 | ||
|
|
23a2e5fee7 | ||
|
|
d01aa19ffa | ||
|
|
8cd7d5a753 | ||
|
|
42faa3ccf8 | ||
|
|
4694d60fc5 | ||
|
|
7dc689eee7 | ||
|
|
5df011ad4b | ||
|
|
200cf737bb |
53
.github/workflows/ci.yml
vendored
53
.github/workflows/ci.yml
vendored
@@ -30,31 +30,56 @@ jobs:
|
||||
matrix:
|
||||
os: [ubuntu-latest, macos-latest, windows-latest]
|
||||
runs-on: ${{ matrix.os }}
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Use Node.js 18
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
# https://github.com/microsoft/playwright-mcp/issues/344
|
||||
node-version: '18.19'
|
||||
cache: 'npm'
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
- name: Playwright install
|
||||
run: npx playwright install --with-deps
|
||||
- name: Install MS Edge
|
||||
# MS Edge is not preinstalled on macOS runners.
|
||||
if: ${{ matrix.os == 'macos-latest' }}
|
||||
run: npx playwright install msedge
|
||||
- name: Build
|
||||
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
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '18'
|
||||
cache: 'npm'
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: Playwright install
|
||||
run: npx playwright install --with-deps
|
||||
|
||||
- name: Install MS Edge
|
||||
if: ${{ matrix.os == 'macos-latest' }} # MS Edge is not preinstalled on macOS runners.
|
||||
run: npx playwright install msedge
|
||||
|
||||
run: npx playwright install --with-deps chromium
|
||||
- name: Build
|
||||
run: npm run build
|
||||
|
||||
- name: Install Playwright browsers
|
||||
run: npx playwright install --with-deps
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
- name: Build and push
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
tags: playwright-mcp-dev:latest
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
load: true
|
||||
- name: Run tests
|
||||
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
|
||||
|
||||
33
.github/workflows/publish.yml
vendored
33
.github/workflows/publish.yml
vendored
@@ -7,7 +7,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
id-token: write
|
||||
id-token: write # Needed for npm provenance
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
@@ -22,3 +22,34 @@ jobs:
|
||||
- run: npm publish --provenance
|
||||
env:
|
||||
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
|
||||
|
||||
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
|
||||
WORKDIR /app
|
||||
|
||||
# Copy package.json and package-lock.json at this stage to leverage the build cache
|
||||
COPY package*.json ./
|
||||
RUN --mount=type=cache,target=/root/.npm,sharing=locked,id=npm-cache \
|
||||
--mount=type=bind,source=package.json,target=package.json \
|
||||
--mount=type=bind,source=package-lock.json,target=package-lock.json \
|
||||
npm ci --omit=dev && \
|
||||
# Install system dependencies for playwright
|
||||
npx -y playwright-core install-deps chromium
|
||||
|
||||
# Install dependencies
|
||||
RUN npm ci
|
||||
# ------------------------------
|
||||
# Builder
|
||||
# ------------------------------
|
||||
FROM base AS builder
|
||||
|
||||
# Install chromium and its dependencies, but only for headless mode
|
||||
RUN npx -y playwright install --with-deps --only-shell chromium
|
||||
RUN --mount=type=cache,target=/root/.npm,sharing=locked,id=npm-cache \
|
||||
--mount=type=bind,source=package.json,target=package.json \
|
||||
--mount=type=bind,source=package-lock.json,target=package-lock.json \
|
||||
npm ci
|
||||
|
||||
# Copy the rest of the app
|
||||
COPY . .
|
||||
COPY *.json *.js *.ts .
|
||||
COPY src src/
|
||||
|
||||
# Build the app
|
||||
RUN npm run build
|
||||
|
||||
# ------------------------------
|
||||
# Browser
|
||||
# ------------------------------
|
||||
# Cache optimization:
|
||||
# - Browser is downloaded only when node_modules or Playwright system dependencies change
|
||||
# - Cache is reused when only source code changes
|
||||
FROM base AS browser
|
||||
|
||||
RUN npx -y playwright-core install --no-shell chromium
|
||||
|
||||
# ------------------------------
|
||||
# Runtime
|
||||
# ------------------------------
|
||||
FROM base
|
||||
|
||||
ARG PLAYWRIGHT_BROWSERS_PATH
|
||||
ARG USERNAME=node
|
||||
ENV NODE_ENV=production
|
||||
|
||||
# Set the correct ownership for the runtime user on production `node_modules`
|
||||
RUN chown -R ${USERNAME}:${USERNAME} node_modules
|
||||
|
||||
USER ${USERNAME}
|
||||
|
||||
COPY --from=browser --chown=${USERNAME}:${USERNAME} ${PLAYWRIGHT_BROWSERS_PATH} ${PLAYWRIGHT_BROWSERS_PATH}
|
||||
COPY --chown=${USERNAME}:${USERNAME} cli.js package.json ./
|
||||
COPY --from=builder --chown=${USERNAME}:${USERNAME} /app/lib /app/lib
|
||||
|
||||
# Run in headless and only with chromium (other browsers need more dependencies not included in this image)
|
||||
ENTRYPOINT ["node", "cli.js", "--headless", "--browser", "chromium"]
|
||||
ENTRYPOINT ["node", "cli.js", "--headless", "--browser", "chromium", "--no-sandbox"]
|
||||
|
||||
609
README.md
609
README.md
@@ -4,25 +4,22 @@ A Model Context Protocol (MCP) server that provides browser automation capabilit
|
||||
|
||||
### Key Features
|
||||
|
||||
- **Fast and lightweight**: Uses Playwright's accessibility tree, not pixel-based input.
|
||||
- **LLM-friendly**: No vision models needed, operates purely on structured data.
|
||||
- **Deterministic tool application**: Avoids ambiguity common with screenshot-based approaches.
|
||||
- **Fast and lightweight**. Uses Playwright's accessibility tree, not pixel-based input.
|
||||
- **LLM-friendly**. No vision models needed, operates purely on structured data.
|
||||
- **Deterministic tool application**. Avoids ambiguity common with screenshot-based approaches.
|
||||
|
||||
### Use Cases
|
||||
|
||||
- Web navigation and form-filling
|
||||
- Data extraction from structured content
|
||||
- Automated testing driven by LLMs
|
||||
- General-purpose browser interaction for agents
|
||||
### Requirements
|
||||
- Node.js 18 or newer
|
||||
- VS Code, Cursor, Windsurf, Claude Desktop or any other MCP client
|
||||
|
||||
<!--
|
||||
// Generate using:
|
||||
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
|
||||
{
|
||||
@@ -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
|
||||
# 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.
|
||||
</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:
|
||||
- `chrome`, `firefox`, `webkit`, `msedge`
|
||||
- Chrome channels: `chrome-beta`, `chrome-canary`, `chrome-dev`
|
||||
- Edge channels: `msedge-beta`, `msedge-canary`, `msedge-dev`
|
||||
- Default: `chrome`
|
||||
- `--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
|
||||
- `--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.
|
||||
- `--vision`: Run server that uses screenshots (Aria snapshots are used by default)
|
||||
- `--config <path>`: Path to the configuration file
|
||||
```js
|
||||
{
|
||||
"mcpServers": {
|
||||
"playwright": {
|
||||
"command": "npx",
|
||||
"args": [
|
||||
"@playwright/mcp@latest"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
</details>
|
||||
|
||||
<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
|
||||
|
||||
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.
|
||||
|
||||
```
|
||||
- `%USERPROFILE%\AppData\Local\ms-playwright\mcp-{channel}-profile` on Windows
|
||||
- `~/Library/Caches/ms-playwright/mcp-{channel}-profile` on macOS
|
||||
- `~/.cache/ms-playwright/mcp-{channel}-profile` on Linux
|
||||
**Persistent profile**
|
||||
|
||||
All the logged in information will be stored in the persistent profile, you can delete it between sessions if you'd like to clear the offline state.
|
||||
Persistent profile is located at the following locations and you can override it with the `--user-data-dir` argument.
|
||||
|
||||
```bash
|
||||
# Windows
|
||||
%USERPROFILE%\AppData\Local\ms-playwright\mcp-{channel}-profile
|
||||
|
||||
# macOS
|
||||
- ~/Library/Caches/ms-playwright/mcp-{channel}-profile
|
||||
|
||||
# Linux
|
||||
- ~/.cache/ms-playwright/mcp-{channel}-profile
|
||||
```
|
||||
|
||||
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
|
||||
|
||||
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
|
||||
{
|
||||
@@ -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)
|
||||
browserName?: 'chromium' | 'firefox' | 'webkit';
|
||||
|
||||
// Keep the browser profile in memory, do not save it to disk.
|
||||
isolated?: boolean;
|
||||
|
||||
// Path to user data directory for browser profile persistence
|
||||
userDataDir?: string;
|
||||
|
||||
@@ -152,23 +278,24 @@ The Playwright MCP server can be configured using a JSON configuration file. Her
|
||||
// Directory for output files
|
||||
outputDir?: string;
|
||||
|
||||
// Tool-specific configurations
|
||||
tools?: {
|
||||
browser_take_screenshot?: {
|
||||
// Disable base64-encoded image responses
|
||||
omitBase64?: boolean;
|
||||
}
|
||||
}
|
||||
// Network configuration
|
||||
network?: {
|
||||
// List of origins to allow the browser to request. Default is to allow all. Origins matching both `allowedOrigins` and `blockedOrigins` will be blocked.
|
||||
allowedOrigins?: string[];
|
||||
|
||||
// List of origins to block the browser to request. Origins matching both `allowedOrigins` and `blockedOrigins` will be blocked.
|
||||
blockedOrigins?: string[];
|
||||
};
|
||||
|
||||
/**
|
||||
* Do not send image responses to the client.
|
||||
*/
|
||||
noImageResponses?: boolean;
|
||||
}
|
||||
```
|
||||
</details>
|
||||
|
||||
You can specify the configuration file using the `--config` command line option:
|
||||
|
||||
```bash
|
||||
npx @playwright/mcp@latest --config path/to/config.json
|
||||
```
|
||||
|
||||
### Running on Linux
|
||||
### Standalone MCP server
|
||||
|
||||
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.
|
||||
@@ -189,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.
|
||||
|
||||
@@ -198,7 +326,7 @@ And then in MCP client config, set the `url` to the SSE endpoint:
|
||||
"mcpServers": {
|
||||
"playwright": {
|
||||
"command": "docker",
|
||||
"args": ["run", "-i", "--rm", "--init", "mcp/playwright"]
|
||||
"args": ["run", "-i", "--rm", "--init", "--pull=always", "mcr.microsoft.com/playwright/mcp"]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -207,30 +335,33 @@ And then in MCP client config, set the `url` to the SSE endpoint:
|
||||
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
|
||||
import http from 'http';
|
||||
|
||||
import { createServer } from '@playwright/mcp';
|
||||
import { createConnection } from '@playwright/mcp';
|
||||
import { SSEServerTransport } from '@modelcontextprotocol/sdk/server/sse.js';
|
||||
|
||||
http.createServer(async (req, res) => {
|
||||
// ...
|
||||
|
||||
// Creates a headless Playwright MCP server with SSE transport
|
||||
const mcpServer = await createServer({ headless: true });
|
||||
const connection = await createConnection({ browser: { launchOptions: { headless: true } } });
|
||||
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:
|
||||
|
||||
@@ -256,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
|
||||
X Y coordinate space, based on the provided screenshot.
|
||||
|
||||
<!--- Tools generated by update-readme.js -->
|
||||
|
||||
<!--- Generated by update-readme.js -->
|
||||
|
||||
### Snapshot-based Interactions
|
||||
<details>
|
||||
<summary><b>Interactions</b></summary>
|
||||
|
||||
<!-- NOTE: This has been generated via update-readme.js -->
|
||||
|
||||
- **browser_snapshot**
|
||||
- Title: Page snapshot
|
||||
- Description: Capture accessibility snapshot of the current page, this is better than screenshot
|
||||
- Parameters: None
|
||||
- Read-only: **true**
|
||||
|
||||
<!-- NOTE: This has been generated via update-readme.js -->
|
||||
|
||||
- **browser_click**
|
||||
- Title: Click
|
||||
- Description: Perform click on a web page
|
||||
- Parameters:
|
||||
- `element` (string): Human-readable element description used to obtain permission to interact with the element
|
||||
- `ref` (string): Exact target element reference from the page snapshot
|
||||
- Read-only: **false**
|
||||
|
||||
<!-- NOTE: This has been generated via update-readme.js -->
|
||||
|
||||
- **browser_drag**
|
||||
- Title: Drag mouse
|
||||
- Description: Perform drag and drop between two elements
|
||||
- Parameters:
|
||||
- `startElement` (string): Human-readable source element description used to obtain the permission to interact with the element
|
||||
- `startRef` (string): Exact source element reference from the page snapshot
|
||||
- `endElement` (string): Human-readable target element description used to obtain the permission to interact with the element
|
||||
- `endRef` (string): Exact target element reference from the page snapshot
|
||||
- Read-only: **false**
|
||||
|
||||
<!-- NOTE: This has been generated via update-readme.js -->
|
||||
|
||||
- **browser_hover**
|
||||
- Title: Hover mouse
|
||||
- Description: Hover over element on page
|
||||
- Parameters:
|
||||
- `element` (string): Human-readable element description used to obtain permission to interact with the element
|
||||
- `ref` (string): Exact target element reference from the page snapshot
|
||||
- Read-only: **true**
|
||||
|
||||
<!-- NOTE: This has been generated via update-readme.js -->
|
||||
|
||||
- **browser_type**
|
||||
- Title: Type text
|
||||
- Description: Type text into editable element
|
||||
- Parameters:
|
||||
- `element` (string): Human-readable element description used to obtain permission to interact with the element
|
||||
@@ -303,54 +443,256 @@ X Y coordinate space, based on the provided screenshot.
|
||||
- `text` (string): Text to type into the element
|
||||
- `submit` (boolean, optional): Whether to submit entered text (press Enter after)
|
||||
- `slowly` (boolean, optional): Whether to type one character at a time. Useful for triggering key handlers in the page. By default entire text is filled in at once.
|
||||
- Read-only: **false**
|
||||
|
||||
<!-- NOTE: This has been generated via update-readme.js -->
|
||||
|
||||
- **browser_select_option**
|
||||
- Title: Select option
|
||||
- Description: Select an option in a dropdown
|
||||
- Parameters:
|
||||
- `element` (string): Human-readable element description used to obtain permission to interact with the element
|
||||
- `ref` (string): Exact target element reference from the page snapshot
|
||||
- `values` (array): Array of values to select in the dropdown. This can be a single value or multiple values.
|
||||
- Read-only: **false**
|
||||
|
||||
<!-- 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 -->
|
||||
|
||||
- **browser_take_screenshot**
|
||||
- Title: Take a screenshot
|
||||
- Description: Take a screenshot of the current page. You can't perform actions based on the screenshot, use browser_snapshot for actions.
|
||||
- Parameters:
|
||||
- `raw` (boolean, optional): Whether to return without compression (in PNG format). Default is false, which returns a JPEG image.
|
||||
- `filename` (string, optional): File name to save the screenshot to. Defaults to `page-{timestamp}.{png|jpeg}` if not specified.
|
||||
- `element` (string, optional): Human-readable element description used to obtain permission to screenshot the element. If not provided, the screenshot will be taken of viewport. If element is provided, ref must be provided too.
|
||||
- `ref` (string, optional): Exact target element reference from the page snapshot. If not provided, the screenshot will be taken of viewport. If ref is provided, element must be provided too.
|
||||
- Read-only: **true**
|
||||
|
||||
### 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 -->
|
||||
|
||||
- **browser_screen_capture**
|
||||
- Title: Take a screenshot
|
||||
- Description: Take a screenshot of the current page
|
||||
- Parameters: None
|
||||
- Read-only: **true**
|
||||
|
||||
<!-- NOTE: This has been generated via update-readme.js -->
|
||||
|
||||
- **browser_screen_move_mouse**
|
||||
- Title: Move mouse
|
||||
- Description: Move mouse to a given position
|
||||
- Parameters:
|
||||
- `element` (string): Human-readable element description used to obtain permission to interact with the element
|
||||
- `x` (number): X coordinate
|
||||
- `y` (number): Y coordinate
|
||||
- Read-only: **true**
|
||||
|
||||
<!-- NOTE: This has been generated via update-readme.js -->
|
||||
|
||||
- **browser_screen_click**
|
||||
- Title: Click
|
||||
- Description: Click left mouse button
|
||||
- Parameters:
|
||||
- `element` (string): Human-readable element description used to obtain permission to interact with the element
|
||||
- `x` (number): X coordinate
|
||||
- `y` (number): Y coordinate
|
||||
- Read-only: **false**
|
||||
|
||||
<!-- NOTE: This has been generated via update-readme.js -->
|
||||
|
||||
- **browser_screen_drag**
|
||||
- Title: Drag mouse
|
||||
- Description: Drag left mouse button
|
||||
- Parameters:
|
||||
- `element` (string): Human-readable element description used to obtain permission to interact with the element
|
||||
@@ -358,143 +700,58 @@ X Y coordinate space, based on the provided screenshot.
|
||||
- `startY` (number): Start Y coordinate
|
||||
- `endX` (number): End X coordinate
|
||||
- `endY` (number): End Y coordinate
|
||||
- Read-only: **false**
|
||||
|
||||
<!-- NOTE: This has been generated via update-readme.js -->
|
||||
|
||||
- **browser_screen_type**
|
||||
- Title: Type text
|
||||
- Description: Type text
|
||||
- Parameters:
|
||||
- `text` (string): Text to type into the element
|
||||
- `submit` (boolean, optional): Whether to submit entered text (press Enter after)
|
||||
|
||||
### 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
|
||||
- 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`
|
||||
|
||||
### Console
|
||||
- Read-only: **false**
|
||||
|
||||
<!-- NOTE: This has been generated via update-readme.js -->
|
||||
|
||||
- **browser_console_messages**
|
||||
- Description: Returns all console messages
|
||||
- Parameters: None
|
||||
|
||||
### Files and Media
|
||||
- **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.
|
||||
|
||||
<!-- 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
|
||||
- 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**
|
||||
|
||||
### Testing
|
||||
</details>
|
||||
|
||||
<!-- NOTE: This has been generated via update-readme.js -->
|
||||
|
||||
- **browser_generate_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
|
||||
|
||||
<!--- End of generated section -->
|
||||
<!--- End of tools generated section -->
|
||||
|
||||
34
config.d.ts
vendored
34
config.d.ts
vendored
@@ -28,6 +28,11 @@ export type Config = {
|
||||
*/
|
||||
browserName?: 'chromium' | 'firefox' | 'webkit';
|
||||
|
||||
/**
|
||||
* Keep the browser profile in memory, do not save it to disk.
|
||||
*/
|
||||
isolated?: boolean;
|
||||
|
||||
/**
|
||||
* Path to a user data directory for browser profile persistence.
|
||||
* Temporary directory is created by default.
|
||||
@@ -40,7 +45,7 @@ export type Config = {
|
||||
*
|
||||
* This is useful for settings options like `channel`, `headless`, `executablePath`, etc.
|
||||
*/
|
||||
launchOptions?: playwright.BrowserLaunchOptions;
|
||||
launchOptions?: playwright.LaunchOptions;
|
||||
|
||||
/**
|
||||
* Context options for the browser context.
|
||||
@@ -89,25 +94,30 @@ export type Config = {
|
||||
*/
|
||||
vision?: boolean;
|
||||
|
||||
/**
|
||||
* Whether to save the Playwright trace of the session into the output directory.
|
||||
*/
|
||||
saveTrace?: boolean;
|
||||
|
||||
/**
|
||||
* The directory to save output files.
|
||||
*/
|
||||
outputDir?: string;
|
||||
|
||||
network?: {
|
||||
/**
|
||||
* Configuration for specific tools.
|
||||
* List of origins to allow the browser to request. Default is to allow all. Origins matching both `allowedOrigins` and `blockedOrigins` will be blocked.
|
||||
*/
|
||||
tools?: {
|
||||
/**
|
||||
* Configuration for the browser_take_screenshot tool.
|
||||
*/
|
||||
browser_take_screenshot?: {
|
||||
allowedOrigins?: string[];
|
||||
|
||||
/**
|
||||
* Whether to disable base64-encoded image responses to the clients that
|
||||
* don't support binary data or prefer to save on tokens.
|
||||
* List of origins to block the browser to request. Origins matching both `allowedOrigins` and `blockedOrigins` will be blocked.
|
||||
*/
|
||||
omitBase64?: boolean;
|
||||
}
|
||||
}
|
||||
blockedOrigins?: string[];
|
||||
};
|
||||
|
||||
/**
|
||||
* Whether to send image responses to the client. Can be "allow", "omit", or "auto". Defaults to "auto", which sends images if the client can display them.
|
||||
*/
|
||||
imageResponses?: 'allow' | 'omit' | 'auto';
|
||||
};
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
Generate test for scenario:
|
||||
Use Playwright tools to generate test for scenario:
|
||||
|
||||
## GitHub PR Checks Navigation Checklist
|
||||
|
||||
|
||||
11
index.d.ts
vendored
11
index.d.ts
vendored
@@ -16,8 +16,15 @@
|
||||
*/
|
||||
|
||||
import type { Server } from '@modelcontextprotocol/sdk/server/index.js';
|
||||
|
||||
import type { Config } from './config';
|
||||
import type { Transport } from '@modelcontextprotocol/sdk/shared/transport.js';
|
||||
import type { BrowserContext } from 'playwright';
|
||||
|
||||
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, contextGetter?: () => Promise<BrowserContext>): Promise<Connection>;
|
||||
export {};
|
||||
|
||||
4
index.js
4
index.js
@@ -15,5 +15,5 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { createServer } from './lib/index';
|
||||
export default { createServer };
|
||||
import { createConnection } from './lib/index.js';
|
||||
export { createConnection };
|
||||
|
||||
159
package-lock.json
generated
159
package-lock.json
generated
@@ -1,18 +1,18 @@
|
||||
{
|
||||
"name": "@playwright/mcp",
|
||||
"version": "0.0.20",
|
||||
"version": "0.0.28",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@playwright/mcp",
|
||||
"version": "0.0.20",
|
||||
"version": "0.0.28",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@modelcontextprotocol/sdk": "^1.10.1",
|
||||
"@modelcontextprotocol/sdk": "^1.11.0",
|
||||
"commander": "^13.1.0",
|
||||
"playwright": "1.53.0-alpha-1746218818000",
|
||||
"yaml": "^2.7.1",
|
||||
"debug": "^4.4.1",
|
||||
"playwright": "1.53.0-alpha-2025-05-27",
|
||||
"zod-to-json-schema": "^3.24.4"
|
||||
},
|
||||
"bin": {
|
||||
@@ -21,8 +21,9 @@
|
||||
"devDependencies": {
|
||||
"@eslint/eslintrc": "^3.2.0",
|
||||
"@eslint/js": "^9.19.0",
|
||||
"@playwright/test": "1.53.0-alpha-1746218818000",
|
||||
"@playwright/test": "1.53.0-alpha-2025-05-27",
|
||||
"@stylistic/eslint-plugin": "^3.0.1",
|
||||
"@types/debug": "^4.1.12",
|
||||
"@types/node": "^22.13.10",
|
||||
"@typescript-eslint/eslint-plugin": "^8.26.1",
|
||||
"@typescript-eslint/parser": "^8.26.1",
|
||||
@@ -228,9 +229,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@modelcontextprotocol/sdk": {
|
||||
"version": "1.10.1",
|
||||
"resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.10.1.tgz",
|
||||
"integrity": "sha512-xNYdFdkJqEfIaTVP1gPKoEvluACHZsHZegIoICX8DM1o6Qf3G5u2BQJHmgd0n4YgRPqqK/u1ujQvrgAxxSJT9w==",
|
||||
"version": "1.11.0",
|
||||
"resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.11.0.tgz",
|
||||
"integrity": "sha512-k/1pb70eD638anoi0e8wUGAlbMJXyvdV4p62Ko+EZ7eBe1xMx8Uhak1R5DgfoofsK5IBBnRwsYGTaLZl+6/+RQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"content-type": "^1.0.5",
|
||||
@@ -287,13 +288,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@playwright/test": {
|
||||
"version": "1.53.0-alpha-1746218818000",
|
||||
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.53.0-alpha-1746218818000.tgz",
|
||||
"integrity": "sha512-J05FD0oOCVbjbp4IjQi5tOPKywchi5EENS9jRjgkA5N9jd/+BaZ3jT8HlLMIgALdk/eLsprQa7vh9h45Q1FOPA==",
|
||||
"version": "1.53.0-alpha-2025-05-27",
|
||||
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.53.0-alpha-2025-05-27.tgz",
|
||||
"integrity": "sha512-G2zG56kEQOWhk3nQyPKH5u41jyQw5jx+Kga5huUi7RjBjPEnNtiCMNXMNGCh6dDYCIyQkLJvz/o1H/QN26HLsg==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"playwright": "1.53.0-alpha-1746218818000"
|
||||
"playwright": "1.53.0-alpha-2025-05-27"
|
||||
},
|
||||
"bin": {
|
||||
"playwright": "cli.js"
|
||||
@@ -355,6 +356,16 @@
|
||||
"url": "https://github.com/sponsors/jonschlinkert"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/debug": {
|
||||
"version": "4.1.12",
|
||||
"resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.12.tgz",
|
||||
"integrity": "sha512-vIChWdVG3LG1SMxEvI/AK+FWJthlrqlTu7fbrlywTkkaONwk/UAGaULXRlf8vkzFBLVm0zkMdCquhL5aOjhXPQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/ms": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/estree": {
|
||||
"version": "1.0.6",
|
||||
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.6.tgz",
|
||||
@@ -376,6 +387,13 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/ms": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz",
|
||||
"integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/node": {
|
||||
"version": "22.13.10",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.13.10.tgz",
|
||||
@@ -854,29 +872,6 @@
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/body-parser/node_modules/debug": {
|
||||
"version": "4.4.0",
|
||||
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.0.tgz",
|
||||
"integrity": "sha512-6WTZ/IxCY/T6BALoZHaE4ctp9xm+Z5kY/pzYaCHRFeyVhojxlrm+46y68HA6hr0TcwEssoxNiDEUJQjfPZ/RYA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ms": "^2.1.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"supports-color": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/body-parser/node_modules/ms": {
|
||||
"version": "2.1.3",
|
||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
||||
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/body-parser/node_modules/qs": {
|
||||
"version": "6.14.0",
|
||||
"resolved": "https://registry.npmjs.org/qs/-/qs-6.14.0.tgz",
|
||||
@@ -1157,12 +1152,12 @@
|
||||
}
|
||||
},
|
||||
"node_modules/debug": {
|
||||
"version": "4.3.6",
|
||||
"resolved": "https://registry.npmjs.org/debug/-/debug-4.3.6.tgz",
|
||||
"integrity": "sha512-O/09Bd4Z1fBrU4VzkhFqVgpPzaGbw6Sm9FEkBT1A/YBXQFGuuSxa1dN2nxgxS34JmKXqYx8CZAwEVoJFImUXIg==",
|
||||
"version": "4.4.1",
|
||||
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.1.tgz",
|
||||
"integrity": "sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ms": "2.1.2"
|
||||
"ms": "^2.1.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6.0"
|
||||
@@ -1827,6 +1822,29 @@
|
||||
"express": "^4.11 || 5 || ^5.0.0-beta.1"
|
||||
}
|
||||
},
|
||||
"node_modules/express/node_modules/debug": {
|
||||
"version": "4.3.6",
|
||||
"resolved": "https://registry.npmjs.org/debug/-/debug-4.3.6.tgz",
|
||||
"integrity": "sha512-O/09Bd4Z1fBrU4VzkhFqVgpPzaGbw6Sm9FEkBT1A/YBXQFGuuSxa1dN2nxgxS34JmKXqYx8CZAwEVoJFImUXIg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ms": "2.1.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"supports-color": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/express/node_modules/ms": {
|
||||
"version": "2.1.2",
|
||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz",
|
||||
"integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/fast-deep-equal": {
|
||||
"version": "3.1.3",
|
||||
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
|
||||
@@ -1931,29 +1949,6 @@
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/finalhandler/node_modules/debug": {
|
||||
"version": "4.4.0",
|
||||
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.0.tgz",
|
||||
"integrity": "sha512-6WTZ/IxCY/T6BALoZHaE4ctp9xm+Z5kY/pzYaCHRFeyVhojxlrm+46y68HA6hr0TcwEssoxNiDEUJQjfPZ/RYA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ms": "^2.1.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"supports-color": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/finalhandler/node_modules/ms": {
|
||||
"version": "2.1.3",
|
||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
||||
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/find-root": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/find-root/-/find-root-1.1.0.tgz",
|
||||
@@ -3004,9 +2999,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/ms": {
|
||||
"version": "2.1.2",
|
||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz",
|
||||
"integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==",
|
||||
"version": "2.1.3",
|
||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
||||
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/natural-compare": {
|
||||
@@ -3299,12 +3294,12 @@
|
||||
}
|
||||
},
|
||||
"node_modules/playwright": {
|
||||
"version": "1.53.0-alpha-1746218818000",
|
||||
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.53.0-alpha-1746218818000.tgz",
|
||||
"integrity": "sha512-mVIjtdqIawIqWVyvCaLmV6XTALCT4oWWrbMjoHyyWRln3jQjnm3RUO9LkaINz+Yh88O3FkuY6RfjGXPXeFeJ4Q==",
|
||||
"version": "1.53.0-alpha-2025-05-27",
|
||||
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.53.0-alpha-2025-05-27.tgz",
|
||||
"integrity": "sha512-CD0BTwV5javEJ3hf3rhFJEvR3ZoWsu4HUQFfLH2mtVVe+grGPCP55FnlOjpDnJ5pP4Kibe/ZcmgPDg56ic/y9g==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"playwright-core": "1.53.0-alpha-1746218818000"
|
||||
"playwright-core": "1.53.0-alpha-2025-05-27"
|
||||
},
|
||||
"bin": {
|
||||
"playwright": "cli.js"
|
||||
@@ -3317,9 +3312,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/playwright-core": {
|
||||
"version": "1.53.0-alpha-1746218818000",
|
||||
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.53.0-alpha-1746218818000.tgz",
|
||||
"integrity": "sha512-iaIZmhO/psGssWpxIprJkFrn2h4xFjgL0jZsKGtReAMZ/XhlqMUJxtSitwWM4BV+wxJIptsZD0s5Ml2KU62Z3w==",
|
||||
"version": "1.53.0-alpha-2025-05-27",
|
||||
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.53.0-alpha-2025-05-27.tgz",
|
||||
"integrity": "sha512-uVxs7YjENoBMFyQhsZWImIBuo/oX7Mu63djhQN3qFz/NdXA/rOAnP73XzfB+VJNwRMKgIOtqHQgjOG3Rl/lm0A==",
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
"playwright-core": "cli.js"
|
||||
@@ -3714,12 +3709,6 @@
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/send/node_modules/ms": {
|
||||
"version": "2.1.3",
|
||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
||||
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/serve-static": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/serve-static/-/serve-static-2.1.0.tgz",
|
||||
@@ -4350,18 +4339,6 @@
|
||||
"integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==",
|
||||
"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": {
|
||||
"version": "0.1.0",
|
||||
"resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz",
|
||||
|
||||
13
package.json
13
package.json
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@playwright/mcp",
|
||||
"version": "0.0.20",
|
||||
"version": "0.0.28",
|
||||
"description": "Playwright Tools for MCP",
|
||||
"type": "module",
|
||||
"repository": {
|
||||
@@ -17,7 +17,7 @@
|
||||
"license": "Apache-2.0",
|
||||
"scripts": {
|
||||
"build": "tsc",
|
||||
"lint": "npm run update-readme && eslint .",
|
||||
"lint": "npm run update-readme && eslint . && tsc --noEmit",
|
||||
"update-readme": "node utils/update-readme.js",
|
||||
"watch": "tsc --watch",
|
||||
"test": "playwright test",
|
||||
@@ -35,17 +35,18 @@
|
||||
}
|
||||
},
|
||||
"dependencies": {
|
||||
"@modelcontextprotocol/sdk": "^1.10.1",
|
||||
"@modelcontextprotocol/sdk": "^1.11.0",
|
||||
"commander": "^13.1.0",
|
||||
"playwright": "1.53.0-alpha-1746218818000",
|
||||
"yaml": "^2.7.1",
|
||||
"debug": "^4.4.1",
|
||||
"playwright": "1.53.0-alpha-2025-05-27",
|
||||
"zod-to-json-schema": "^3.24.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/eslintrc": "^3.2.0",
|
||||
"@eslint/js": "^9.19.0",
|
||||
"@playwright/test": "1.53.0-alpha-1746218818000",
|
||||
"@playwright/test": "1.53.0-alpha-2025-05-27",
|
||||
"@stylistic/eslint-plugin": "^3.0.1",
|
||||
"@types/debug": "^4.1.12",
|
||||
"@types/node": "^22.13.10",
|
||||
"@typescript-eslint/eslint-plugin": "^8.26.1",
|
||||
"@typescript-eslint/parser": "^8.26.1",
|
||||
|
||||
@@ -29,6 +29,14 @@ export default defineConfig<TestOptions>({
|
||||
{ name: 'chrome' },
|
||||
{ name: 'msedge', use: { mcpBrowser: 'msedge' } },
|
||||
{ name: 'chromium', use: { mcpBrowser: 'chromium' } },
|
||||
...process.env.MCP_IN_DOCKER ? [{
|
||||
name: 'chromium-docker',
|
||||
grep: /browser_navigate|browser_click/,
|
||||
use: {
|
||||
mcpBrowser: 'chromium',
|
||||
mcpMode: 'docker' as const
|
||||
}
|
||||
}] : [],
|
||||
{ name: 'firefox', use: { mcpBrowser: 'firefox' } },
|
||||
{ name: 'webkit', use: { mcpBrowser: 'webkit' } },
|
||||
],
|
||||
|
||||
211
src/browserContextFactory.ts
Normal file
211
src/browserContextFactory.ts
Normal file
@@ -0,0 +1,211 @@
|
||||
/**
|
||||
* 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 os from 'node:os';
|
||||
import path from 'node:path';
|
||||
|
||||
import debug from 'debug';
|
||||
import * as playwright from 'playwright';
|
||||
|
||||
import type { FullConfig } from './config.js';
|
||||
|
||||
const testDebug = debug('pw:mcp:test');
|
||||
|
||||
export function contextFactory(browserConfig: FullConfig['browser']): BrowserContextFactory {
|
||||
if (browserConfig.remoteEndpoint)
|
||||
return new RemoteContextFactory(browserConfig);
|
||||
if (browserConfig.cdpEndpoint)
|
||||
return new CdpContextFactory(browserConfig);
|
||||
if (browserConfig.isolated)
|
||||
return new IsolatedContextFactory(browserConfig);
|
||||
return new PersistentContextFactory(browserConfig);
|
||||
}
|
||||
|
||||
export interface BrowserContextFactory {
|
||||
createContext(): Promise<{ browserContext: playwright.BrowserContext, close: () => Promise<void> }>;
|
||||
}
|
||||
|
||||
class BaseContextFactory implements BrowserContextFactory {
|
||||
readonly browserConfig: FullConfig['browser'];
|
||||
protected _browserPromise: Promise<playwright.Browser> | undefined;
|
||||
readonly name: string;
|
||||
|
||||
constructor(name: string, browserConfig: FullConfig['browser']) {
|
||||
this.name = name;
|
||||
this.browserConfig = browserConfig;
|
||||
}
|
||||
|
||||
protected async _obtainBrowser(): Promise<playwright.Browser> {
|
||||
if (this._browserPromise)
|
||||
return this._browserPromise;
|
||||
testDebug(`obtain browser (${this.name})`);
|
||||
this._browserPromise = this._doObtainBrowser();
|
||||
void this._browserPromise.then(browser => {
|
||||
browser.on('disconnected', () => {
|
||||
this._browserPromise = undefined;
|
||||
});
|
||||
}).catch(() => {
|
||||
this._browserPromise = undefined;
|
||||
});
|
||||
return this._browserPromise;
|
||||
}
|
||||
|
||||
protected async _doObtainBrowser(): Promise<playwright.Browser> {
|
||||
throw new Error('Not implemented');
|
||||
}
|
||||
|
||||
async createContext(): Promise<{ browserContext: playwright.BrowserContext, close: () => Promise<void> }> {
|
||||
testDebug(`create browser context (${this.name})`);
|
||||
const browser = await this._obtainBrowser();
|
||||
const browserContext = await this._doCreateContext(browser);
|
||||
return { browserContext, close: () => this._closeBrowserContext(browserContext, browser) };
|
||||
}
|
||||
|
||||
protected async _doCreateContext(browser: playwright.Browser): Promise<playwright.BrowserContext> {
|
||||
throw new Error('Not implemented');
|
||||
}
|
||||
|
||||
private async _closeBrowserContext(browserContext: playwright.BrowserContext, browser: playwright.Browser) {
|
||||
testDebug(`close browser context (${this.name})`);
|
||||
if (browser.contexts().length === 1)
|
||||
this._browserPromise = undefined;
|
||||
await browserContext.close().catch(() => {});
|
||||
if (browser.contexts().length === 0) {
|
||||
testDebug(`close browser (${this.name})`);
|
||||
await browser.close().catch(() => {});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class IsolatedContextFactory extends BaseContextFactory {
|
||||
constructor(browserConfig: FullConfig['browser']) {
|
||||
super('isolated', browserConfig);
|
||||
}
|
||||
|
||||
protected override async _doObtainBrowser(): Promise<playwright.Browser> {
|
||||
const browserType = playwright[this.browserConfig.browserName];
|
||||
return browserType.launch({
|
||||
...this.browserConfig.launchOptions,
|
||||
handleSIGINT: false,
|
||||
handleSIGTERM: false,
|
||||
}).catch(error => {
|
||||
if (error.message.includes('Executable doesn\'t exist'))
|
||||
throw new Error(`Browser specified in your config is not installed. Either install it (likely) or change the config.`);
|
||||
throw error;
|
||||
});
|
||||
}
|
||||
|
||||
protected override async _doCreateContext(browser: playwright.Browser): Promise<playwright.BrowserContext> {
|
||||
return browser.newContext(this.browserConfig.contextOptions);
|
||||
}
|
||||
}
|
||||
|
||||
class CdpContextFactory extends BaseContextFactory {
|
||||
constructor(browserConfig: FullConfig['browser']) {
|
||||
super('cdp', browserConfig);
|
||||
}
|
||||
|
||||
protected override async _doObtainBrowser(): Promise<playwright.Browser> {
|
||||
return playwright.chromium.connectOverCDP(this.browserConfig.cdpEndpoint!);
|
||||
}
|
||||
|
||||
protected override async _doCreateContext(browser: playwright.Browser): Promise<playwright.BrowserContext> {
|
||||
return this.browserConfig.isolated ? await browser.newContext() : browser.contexts()[0];
|
||||
}
|
||||
}
|
||||
|
||||
class RemoteContextFactory extends BaseContextFactory {
|
||||
constructor(browserConfig: FullConfig['browser']) {
|
||||
super('remote', browserConfig);
|
||||
}
|
||||
|
||||
protected override async _doObtainBrowser(): Promise<playwright.Browser> {
|
||||
const url = new URL(this.browserConfig.remoteEndpoint!);
|
||||
url.searchParams.set('browser', this.browserConfig.browserName);
|
||||
if (this.browserConfig.launchOptions)
|
||||
url.searchParams.set('launch-options', JSON.stringify(this.browserConfig.launchOptions));
|
||||
return playwright[this.browserConfig.browserName].connect(String(url));
|
||||
}
|
||||
|
||||
protected override async _doCreateContext(browser: playwright.Browser): Promise<playwright.BrowserContext> {
|
||||
return browser.newContext();
|
||||
}
|
||||
}
|
||||
|
||||
class PersistentContextFactory implements BrowserContextFactory {
|
||||
readonly browserConfig: FullConfig['browser'];
|
||||
private _userDataDirs = new Set<string>();
|
||||
|
||||
constructor(browserConfig: FullConfig['browser']) {
|
||||
this.browserConfig = browserConfig;
|
||||
}
|
||||
|
||||
async createContext(): Promise<{ browserContext: playwright.BrowserContext, close: () => Promise<void> }> {
|
||||
testDebug('create browser context (persistent)');
|
||||
const userDataDir = this.browserConfig.userDataDir ?? await this._createUserDataDir();
|
||||
|
||||
this._userDataDirs.add(userDataDir);
|
||||
testDebug('lock user data dir', userDataDir);
|
||||
|
||||
const browserType = playwright[this.browserConfig.browserName];
|
||||
for (let i = 0; i < 5; i++) {
|
||||
try {
|
||||
const browserContext = await browserType.launchPersistentContext(userDataDir, {
|
||||
...this.browserConfig.launchOptions,
|
||||
...this.browserConfig.contextOptions,
|
||||
handleSIGINT: false,
|
||||
handleSIGTERM: false,
|
||||
});
|
||||
const close = () => this._closeBrowserContext(browserContext, userDataDir);
|
||||
return { browserContext, close };
|
||||
} catch (error: any) {
|
||||
if (error.message.includes('Executable doesn\'t exist'))
|
||||
throw new Error(`Browser specified in your config is not installed. Either install it (likely) or change the config.`);
|
||||
if (error.message.includes('ProcessSingleton') || error.message.includes('Invalid URL')) {
|
||||
// User data directory is already in use, try again.
|
||||
await new Promise(resolve => setTimeout(resolve, 1000));
|
||||
continue;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
throw new Error(`Browser is already in use for ${userDataDir}, use --isolated to run multiple instances of the same browser`);
|
||||
}
|
||||
|
||||
private async _closeBrowserContext(browserContext: playwright.BrowserContext, userDataDir: string) {
|
||||
testDebug('close browser context (persistent)');
|
||||
testDebug('release user data dir', userDataDir);
|
||||
await browserContext.close().catch(() => {});
|
||||
this._userDataDirs.delete(userDataDir);
|
||||
testDebug('close browser context complete (persistent)');
|
||||
}
|
||||
|
||||
private async _createUserDataDir() {
|
||||
let cacheDirectory: string;
|
||||
if (process.platform === 'linux')
|
||||
cacheDirectory = process.env.XDG_CACHE_HOME || path.join(os.homedir(), '.cache');
|
||||
else if (process.platform === 'darwin')
|
||||
cacheDirectory = path.join(os.homedir(), 'Library', 'Caches');
|
||||
else if (process.platform === 'win32')
|
||||
cacheDirectory = process.env.LOCALAPPDATA || path.join(os.homedir(), 'AppData', 'Local');
|
||||
else
|
||||
throw new Error('Unsupported platform: ' + process.platform);
|
||||
const result = path.join(cacheDirectory, 'ms-playwright', `mcp-${this.browserConfig.launchOptions?.channel ?? this.browserConfig?.browserName}-profile`);
|
||||
await fs.promises.mkdir(result, { recursive: true });
|
||||
return result;
|
||||
}
|
||||
}
|
||||
164
src/config.ts
164
src/config.ts
@@ -25,41 +25,84 @@ import type { BrowserContextOptions, LaunchOptions } from 'playwright';
|
||||
import { sanitizeForFilePath } from './tools/utils.js';
|
||||
|
||||
export type CLIOptions = {
|
||||
allowedOrigins?: string[];
|
||||
blockedOrigins?: string[];
|
||||
blockServiceWorkers?: boolean;
|
||||
browser?: string;
|
||||
caps?: string;
|
||||
cdpEndpoint?: string;
|
||||
config?: string;
|
||||
device?: string;
|
||||
executablePath?: string;
|
||||
headless?: boolean;
|
||||
device?: string;
|
||||
userDataDir?: string;
|
||||
port?: number;
|
||||
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;
|
||||
config?: string;
|
||||
};
|
||||
|
||||
const defaultConfig: Config = {
|
||||
const defaultConfig: FullConfig = {
|
||||
browser: {
|
||||
browserName: 'chromium',
|
||||
userDataDir: os.tmpdir(),
|
||||
launchOptions: {
|
||||
channel: 'chrome',
|
||||
headless: os.platform() === 'linux' && !process.env.DISPLAY,
|
||||
chromiumSandbox: true,
|
||||
},
|
||||
contextOptions: {
|
||||
viewport: null,
|
||||
},
|
||||
},
|
||||
network: {
|
||||
allowedOrigins: undefined,
|
||||
blockedOrigins: undefined,
|
||||
},
|
||||
server: {},
|
||||
outputDir: path.join(os.tmpdir(), 'playwright-mcp-output', sanitizeForFilePath(new Date().toISOString())),
|
||||
};
|
||||
|
||||
export async function resolveConfig(cliOptions: CLIOptions): Promise<Config> {
|
||||
const config = await loadConfig(cliOptions.config);
|
||||
type BrowserUserConfig = NonNullable<Config['browser']>;
|
||||
|
||||
export type FullConfig = Config & {
|
||||
browser: Omit<BrowserUserConfig, 'browserName'> & {
|
||||
browserName: 'chromium' | 'firefox' | 'webkit';
|
||||
launchOptions: NonNullable<BrowserUserConfig['launchOptions']>;
|
||||
contextOptions: NonNullable<BrowserUserConfig['contextOptions']>;
|
||||
},
|
||||
network: NonNullable<Config['network']>,
|
||||
outputDir: string;
|
||||
server: NonNullable<Config['server']>,
|
||||
};
|
||||
|
||||
export async function resolveConfig(config: Config): Promise<FullConfig> {
|
||||
return mergeConfig(defaultConfig, config);
|
||||
}
|
||||
|
||||
export async function resolveCLIConfig(cliOptions: CLIOptions): Promise<FullConfig> {
|
||||
const configInFile = await loadConfig(cliOptions.config);
|
||||
const cliOverrides = await configFromCLIOptions(cliOptions);
|
||||
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> {
|
||||
let browserName: 'chromium' | 'firefox' | 'webkit';
|
||||
let browserName: 'chromium' | 'firefox' | 'webkit' | undefined;
|
||||
let channel: string | undefined;
|
||||
switch (cliOptions.browser) {
|
||||
case 'chrome':
|
||||
@@ -80,26 +123,57 @@ export async function configFromCLIOptions(cliOptions: CLIOptions): Promise<Conf
|
||||
case 'webkit':
|
||||
browserName = 'webkit';
|
||||
break;
|
||||
default:
|
||||
browserName = 'chromium';
|
||||
channel = 'chrome';
|
||||
}
|
||||
|
||||
// Launch options
|
||||
const launchOptions: LaunchOptions = {
|
||||
channel,
|
||||
executablePath: cliOptions.executablePath,
|
||||
headless: cliOptions.headless,
|
||||
};
|
||||
|
||||
if (browserName === 'chromium')
|
||||
(launchOptions as any).webSocketPort = await findFreePort();
|
||||
// --no-sandbox was passed, disable the sandbox
|
||||
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: {
|
||||
browserName,
|
||||
userDataDir: cliOptions.userDataDir ?? await createUserDataDir({ browserName, channel }),
|
||||
isolated: cliOptions.isolated,
|
||||
userDataDir: cliOptions.userDataDir,
|
||||
launchOptions,
|
||||
contextOptions,
|
||||
cdpEndpoint: cliOptions.cdpEndpoint,
|
||||
@@ -110,7 +184,16 @@ export async function configFromCLIOptions(cliOptions: CLIOptions): Promise<Conf
|
||||
},
|
||||
capabilities: cliOptions.caps?.split(',').map((c: string) => c.trim() as ToolCapability),
|
||||
vision: !!cliOptions.vision,
|
||||
network: {
|
||||
allowedOrigins: cliOptions.allowedOrigins,
|
||||
blockedOrigins: cliOptions.blockedOrigins,
|
||||
},
|
||||
saveTrace: cliOptions.saveTrace,
|
||||
outputDir: cliOptions.outputDir,
|
||||
imageResponses: cliOptions.imageResponses,
|
||||
};
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
async function findFreePort() {
|
||||
@@ -135,26 +218,10 @@ async function loadConfig(configFile: string | undefined): Promise<Config> {
|
||||
}
|
||||
}
|
||||
|
||||
async function createUserDataDir(options: { browserName: 'chromium' | 'firefox' | 'webkit', channel: string | undefined }) {
|
||||
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-${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 });
|
||||
export async function outputFile(config: FullConfig, name: string): Promise<string> {
|
||||
await fs.promises.mkdir(config.outputDir, { recursive: true });
|
||||
const fileName = sanitizeForFilePath(name);
|
||||
return path.join(result, fileName);
|
||||
return path.join(config.outputDir, fileName);
|
||||
}
|
||||
|
||||
function pickDefined<T extends object>(obj: T | undefined): Partial<T> {
|
||||
@@ -163,10 +230,10 @@ function pickDefined<T extends object>(obj: T | undefined): Partial<T> {
|
||||
) as Partial<T>;
|
||||
}
|
||||
|
||||
function mergeConfig(base: Config, overrides: Config): Config {
|
||||
const browser: Config['browser'] = {
|
||||
...pickDefined(base.browser),
|
||||
...pickDefined(overrides.browser),
|
||||
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: {
|
||||
...pickDefined(base.browser?.launchOptions),
|
||||
...pickDefined(overrides.browser?.launchOptions),
|
||||
@@ -176,14 +243,25 @@ function mergeConfig(base: Config, overrides: Config): Config {
|
||||
...pickDefined(base.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')
|
||||
if (browser.browserName !== 'chromium' && browser.launchOptions)
|
||||
delete browser.launchOptions.channel;
|
||||
|
||||
return {
|
||||
...pickDefined(base),
|
||||
...pickDefined(overrides),
|
||||
browser,
|
||||
};
|
||||
network: {
|
||||
...pickDefined(base.network),
|
||||
...pickDefined(overrides.network),
|
||||
},
|
||||
server: {
|
||||
...pickDefined(base.server),
|
||||
...pickDefined(overrides.server),
|
||||
},
|
||||
} as FullConfig;
|
||||
}
|
||||
|
||||
98
src/connection.ts
Normal file
98
src/connection.ts
Normal file
@@ -0,0 +1,98 @@
|
||||
/**
|
||||
* Copyright (c) Microsoft Corporation.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { Server as McpServer } from '@modelcontextprotocol/sdk/server/index.js';
|
||||
import { CallToolRequestSchema, ListToolsRequestSchema, Tool as McpTool } from '@modelcontextprotocol/sdk/types.js';
|
||||
import { zodToJsonSchema } from 'zod-to-json-schema';
|
||||
|
||||
import { Context } from './context.js';
|
||||
import { snapshotTools, visionTools } from './tools.js';
|
||||
import { packageJSON } from './package.js';
|
||||
|
||||
import { FullConfig } from './config.js';
|
||||
|
||||
import type { BrowserContextFactory } from './browserContextFactory.js';
|
||||
|
||||
export function createConnection(config: FullConfig, browserContextFactory: BrowserContextFactory): Connection {
|
||||
const allTools = config.vision ? visionTools : snapshotTools;
|
||||
const tools = allTools.filter(tool => !config.capabilities || tool.capability === 'core' || config.capabilities.includes(tool.capability));
|
||||
|
||||
const context = new Context(tools, config, browserContextFactory);
|
||||
const server = new McpServer({ name: 'Playwright', version: packageJSON.version }, {
|
||||
capabilities: {
|
||||
tools: {},
|
||||
}
|
||||
});
|
||||
|
||||
server.setRequestHandler(ListToolsRequestSchema, async () => {
|
||||
return {
|
||||
tools: tools.map(tool => ({
|
||||
name: tool.schema.name,
|
||||
description: tool.schema.description,
|
||||
inputSchema: zodToJsonSchema(tool.schema.inputSchema),
|
||||
annotations: {
|
||||
title: tool.schema.title,
|
||||
readOnlyHint: tool.schema.type === 'readOnly',
|
||||
destructiveHint: tool.schema.type === 'destructive',
|
||||
openWorldHint: true,
|
||||
},
|
||||
})) as McpTool[],
|
||||
};
|
||||
});
|
||||
|
||||
server.setRequestHandler(CallToolRequestSchema, async request => {
|
||||
const errorResult = (...messages: string[]) => ({
|
||||
content: [{ type: 'text', text: messages.join('\n') }],
|
||||
isError: true,
|
||||
});
|
||||
const tool = tools.find(tool => tool.schema.name === request.params.name);
|
||||
if (!tool)
|
||||
return errorResult(`Tool "${request.params.name}" not found`);
|
||||
|
||||
|
||||
const modalStates = context.modalStates().map(state => state.type);
|
||||
if (tool.clearsModalState && !modalStates.includes(tool.clearsModalState))
|
||||
return errorResult(`The tool "${request.params.name}" can only be used when there is related modal state present.`, ...context.modalStatesMarkdown());
|
||||
if (!tool.clearsModalState && modalStates.length)
|
||||
return errorResult(`Tool "${request.params.name}" does not handle the modal state.`, ...context.modalStatesMarkdown());
|
||||
|
||||
try {
|
||||
return await context.run(tool, request.params.arguments);
|
||||
} catch (error) {
|
||||
return errorResult(String(error));
|
||||
}
|
||||
});
|
||||
|
||||
return new Connection(server, context);
|
||||
}
|
||||
|
||||
export class Connection {
|
||||
readonly server: McpServer;
|
||||
readonly context: Context;
|
||||
|
||||
constructor(server: McpServer, context: Context) {
|
||||
this.server = server;
|
||||
this.context = context;
|
||||
this.server.oninitialized = () => {
|
||||
this.context.clientVersion = this.server.getClientVersion();
|
||||
};
|
||||
}
|
||||
|
||||
async close() {
|
||||
await this.server.close();
|
||||
await this.context.close();
|
||||
}
|
||||
}
|
||||
156
src/context.ts
156
src/context.ts
@@ -14,36 +14,50 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import debug from 'debug';
|
||||
import * as playwright from 'playwright';
|
||||
|
||||
import { waitForCompletion } from './tools/utils.js';
|
||||
import { callOnPageNoTrace, waitForCompletion } from './tools/utils.js';
|
||||
import { ManualPromise } from './manualPromise.js';
|
||||
import { Tab } from './tab.js';
|
||||
import { outputFile } from './config.js';
|
||||
|
||||
import type { ImageContent, TextContent } from '@modelcontextprotocol/sdk/types.js';
|
||||
import type { ModalState, Tool, ToolActionResult } from './tools/tool.js';
|
||||
import type { Config } from '../config.js';
|
||||
import { outputFile } from './config.js';
|
||||
import type { FullConfig } from './config.js';
|
||||
import type { BrowserContextFactory } from './browserContextFactory.js';
|
||||
|
||||
type PendingAction = {
|
||||
dialogShown: ManualPromise<void>;
|
||||
};
|
||||
|
||||
const testDebug = debug('pw:mcp:test');
|
||||
|
||||
export class Context {
|
||||
readonly tools: Tool[];
|
||||
readonly config: Config;
|
||||
private _browser: playwright.Browser | undefined;
|
||||
private _browserContext: playwright.BrowserContext | undefined;
|
||||
private _createBrowserContextPromise: Promise<{ browser?: playwright.Browser, browserContext: playwright.BrowserContext }> | undefined;
|
||||
readonly config: FullConfig;
|
||||
private _browserContextPromise: Promise<{ browserContext: playwright.BrowserContext, close: () => Promise<void> }> | undefined;
|
||||
private _browserContextFactory: BrowserContextFactory;
|
||||
private _tabs: Tab[] = [];
|
||||
private _currentTab: Tab | undefined;
|
||||
private _modalStates: (ModalState & { tab: Tab })[] = [];
|
||||
private _pendingAction: PendingAction | undefined;
|
||||
private _downloads: { download: playwright.Download, finished: boolean, outputFile: string }[] = [];
|
||||
clientVersion: { name: string; version: string; } | undefined;
|
||||
|
||||
constructor(tools: Tool[], config: Config) {
|
||||
constructor(tools: Tool[], config: FullConfig, browserContextFactory: BrowserContextFactory) {
|
||||
this.tools = tools;
|
||||
this.config = config;
|
||||
this._browserContextFactory = browserContextFactory;
|
||||
testDebug('create context');
|
||||
}
|
||||
|
||||
clientSupportsImages(): boolean {
|
||||
if (this.config.imageResponses === 'allow')
|
||||
return true;
|
||||
if (this.config.imageResponses === 'omit')
|
||||
return false;
|
||||
return !this.clientVersion?.name.includes('cursor');
|
||||
}
|
||||
|
||||
modalStates(): ModalState[] {
|
||||
@@ -80,7 +94,7 @@ export class Context {
|
||||
}
|
||||
|
||||
async newTab(): Promise<Tab> {
|
||||
const browserContext = await this._ensureBrowserContext();
|
||||
const { browserContext } = await this._ensureBrowserContext();
|
||||
const page = await browserContext.newPage();
|
||||
this._currentTab = this._tabs.find(t => t.page === page)!;
|
||||
return this._currentTab;
|
||||
@@ -92,9 +106,9 @@ export class Context {
|
||||
}
|
||||
|
||||
async ensureTab(): Promise<Tab> {
|
||||
const context = await this._ensureBrowserContext();
|
||||
const { browserContext } = await this._ensureBrowserContext();
|
||||
if (!this._currentTab)
|
||||
await context.newPage();
|
||||
await browserContext.newPage();
|
||||
return this._currentTab!;
|
||||
}
|
||||
|
||||
@@ -104,7 +118,7 @@ export class Context {
|
||||
const lines: string[] = ['### Open tabs'];
|
||||
for (let i = 0; i < this._tabs.length; i++) {
|
||||
const tab = this._tabs[i];
|
||||
const title = await tab.page.title();
|
||||
const title = await tab.title();
|
||||
const url = tab.page.url();
|
||||
const current = tab === this._currentTab ? ' (current)' : '';
|
||||
lines.push(`- ${i + 1}:${current} [${title}] (${url})`);
|
||||
@@ -120,7 +134,7 @@ export class Context {
|
||||
|
||||
async run(tool: Tool, params: Record<string, unknown> | undefined) {
|
||||
// Tab management is done outside of the action() call.
|
||||
const toolResult = await tool.handle(this, tool.schema.inputSchema.parse(params));
|
||||
const toolResult = await tool.handle(this, tool.schema.inputSchema.parse(params || {}));
|
||||
const { code, action, waitForNetwork, captureSnapshot, resultOverride } = toolResult;
|
||||
const racingAction = action ? () => this._raceAgainstModalDialogs(action) : undefined;
|
||||
|
||||
@@ -141,7 +155,7 @@ export class Context {
|
||||
let actionResult: { content?: (ImageContent | TextContent)[] } | undefined;
|
||||
try {
|
||||
if (waitForNetwork)
|
||||
actionResult = await waitForCompletion(this, tab.page, async () => racingAction?.()) ?? undefined;
|
||||
actionResult = await waitForCompletion(this, tab, async () => racingAction?.()) ?? undefined;
|
||||
else
|
||||
actionResult = await racingAction?.() ?? undefined;
|
||||
} finally {
|
||||
@@ -185,7 +199,7 @@ ${code.join('\n')}
|
||||
|
||||
result.push(
|
||||
`- Page URL: ${tab.page.url()}`,
|
||||
`- Page Title: ${await tab.page.title()}`
|
||||
`- Page Title: ${await tab.title()}`
|
||||
);
|
||||
|
||||
if (captureSnapshot && tab.hasSnapshot())
|
||||
@@ -205,10 +219,14 @@ ${code.join('\n')}
|
||||
}
|
||||
|
||||
async waitForTimeout(time: number) {
|
||||
if (this._currentTab && !this._javaScriptBlocked())
|
||||
await this._currentTab.page.evaluate(() => new Promise(f => setTimeout(f, 1000)));
|
||||
else
|
||||
if (!this._currentTab || this._javaScriptBlocked()) {
|
||||
await new Promise(f => setTimeout(f, time));
|
||||
return;
|
||||
}
|
||||
|
||||
await callOnPageNoTrace(this._currentTab.page, page => {
|
||||
return page.evaluate(() => new Promise(f => setTimeout(f, 1000)));
|
||||
});
|
||||
}
|
||||
|
||||
private async _raceAgainstModalDialogs(action: () => Promise<ToolActionResult>): Promise<ToolActionResult> {
|
||||
@@ -268,80 +286,66 @@ ${code.join('\n')}
|
||||
|
||||
if (this._currentTab === tab)
|
||||
this._currentTab = this._tabs[Math.min(index, this._tabs.length - 1)];
|
||||
if (this._browserContext && !this._tabs.length)
|
||||
if (!this._tabs.length)
|
||||
void this.close();
|
||||
}
|
||||
|
||||
async close() {
|
||||
if (!this._browserContext)
|
||||
if (!this._browserContextPromise)
|
||||
return;
|
||||
const browserContext = this._browserContext;
|
||||
const browser = this._browser;
|
||||
this._createBrowserContextPromise = undefined;
|
||||
this._browserContext = undefined;
|
||||
this._browser = undefined;
|
||||
|
||||
await browserContext?.close().then(async () => {
|
||||
await browser?.close();
|
||||
}).catch(() => {});
|
||||
}
|
||||
testDebug('close context');
|
||||
|
||||
private async _ensureBrowserContext() {
|
||||
if (!this._browserContext) {
|
||||
const context = await this._createBrowserContext();
|
||||
this._browser = context.browser;
|
||||
this._browserContext = context.browserContext;
|
||||
for (const page of this._browserContext.pages())
|
||||
this._onPageCreated(page);
|
||||
this._browserContext.on('page', page => this._onPageCreated(page));
|
||||
}
|
||||
return this._browserContext;
|
||||
}
|
||||
const promise = this._browserContextPromise;
|
||||
this._browserContextPromise = undefined;
|
||||
|
||||
private async _createBrowserContext(): Promise<{ browser?: playwright.Browser, browserContext: playwright.BrowserContext }> {
|
||||
if (!this._createBrowserContextPromise) {
|
||||
this._createBrowserContextPromise = this._innerCreateBrowserContext();
|
||||
void this._createBrowserContextPromise.catch(() => {
|
||||
this._createBrowserContextPromise = undefined;
|
||||
await promise.then(async ({ browserContext, close }) => {
|
||||
if (this.config.saveTrace)
|
||||
await browserContext.tracing.stop();
|
||||
await close();
|
||||
});
|
||||
}
|
||||
return this._createBrowserContextPromise;
|
||||
|
||||
private async _setupRequestInterception(context: playwright.BrowserContext) {
|
||||
if (this.config.network?.allowedOrigins?.length) {
|
||||
await context.route('**', route => route.abort('blockedbyclient'));
|
||||
|
||||
for (const origin of this.config.network.allowedOrigins)
|
||||
await context.route(`*://${origin}/**`, route => route.continue());
|
||||
}
|
||||
|
||||
private async _innerCreateBrowserContext(): Promise<{ browser?: playwright.Browser, browserContext: playwright.BrowserContext }> {
|
||||
if (this.config.browser?.remoteEndpoint) {
|
||||
const url = new URL(this.config.browser?.remoteEndpoint);
|
||||
if (this.config.browser.browserName)
|
||||
url.searchParams.set('browser', this.config.browser.browserName);
|
||||
if (this.config.browser.launchOptions)
|
||||
url.searchParams.set('launch-options', JSON.stringify(this.config.browser.launchOptions));
|
||||
const browser = await playwright[this.config.browser?.browserName ?? 'chromium'].connect(String(url));
|
||||
const browserContext = await browser.newContext();
|
||||
return { browser, browserContext };
|
||||
}
|
||||
|
||||
if (this.config.browser?.cdpEndpoint) {
|
||||
const browser = await playwright.chromium.connectOverCDP(this.config.browser.cdpEndpoint);
|
||||
const browserContext = browser.contexts()[0];
|
||||
return { browser, browserContext };
|
||||
}
|
||||
|
||||
const browserContext = await launchPersistentContext(this.config.browser);
|
||||
return { browserContext };
|
||||
if (this.config.network?.blockedOrigins?.length) {
|
||||
for (const origin of this.config.network.blockedOrigins)
|
||||
await context.route(`*://${origin}/**`, route => route.abort('blockedbyclient'));
|
||||
}
|
||||
}
|
||||
|
||||
async function launchPersistentContext(browserConfig: Config['browser']): Promise<playwright.BrowserContext> {
|
||||
try {
|
||||
const browserType = browserConfig?.browserName ? playwright[browserConfig.browserName] : playwright.chromium;
|
||||
return await browserType.launchPersistentContext(browserConfig?.userDataDir || '', { ...browserConfig?.launchOptions, ...browserConfig?.contextOptions });
|
||||
} 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;
|
||||
private _ensureBrowserContext() {
|
||||
if (!this._browserContextPromise) {
|
||||
this._browserContextPromise = this._setupBrowserContext();
|
||||
this._browserContextPromise.catch(() => {
|
||||
this._browserContextPromise = undefined;
|
||||
});
|
||||
}
|
||||
return this._browserContextPromise;
|
||||
}
|
||||
|
||||
export async function generateLocator(locator: playwright.Locator): Promise<string> {
|
||||
return (locator as any)._generateLocatorString();
|
||||
private async _setupBrowserContext(): Promise<{ browserContext: playwright.BrowserContext, close: () => Promise<void> }> {
|
||||
// TODO: move to the browser context factory to make it based on isolation mode.
|
||||
const result = await this._browserContextFactory.createContext();
|
||||
const { browserContext } = result;
|
||||
await this._setupRequestInterception(browserContext);
|
||||
for (const page of browserContext.pages())
|
||||
this._onPageCreated(page);
|
||||
browserContext.on('page', page => this._onPageCreated(page));
|
||||
if (this.config.saveTrace) {
|
||||
await browserContext.tracing.start({
|
||||
name: 'trace',
|
||||
screenshots: false,
|
||||
snapshots: true,
|
||||
sources: false,
|
||||
});
|
||||
}
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
82
src/index.ts
82
src/index.ts
@@ -14,62 +14,32 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { createServerWithTools } from './server.js';
|
||||
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 screen from './tools/screen.js';
|
||||
import testing from './tools/testing.js';
|
||||
import type { Tool } from './tools/tool.js';
|
||||
import { Connection, createConnection as createConnectionImpl } from './connection.js';
|
||||
import { resolveConfig } from './config.js';
|
||||
import { contextFactory } from './browserContextFactory.js';
|
||||
|
||||
import type { Config } from '../config.js';
|
||||
import type { Server } from '@modelcontextprotocol/sdk/server/index.js';
|
||||
import type { BrowserContext } from 'playwright';
|
||||
import type { BrowserContextFactory } from './browserContextFactory.js';
|
||||
|
||||
const snapshotTools: Tool<any>[] = [
|
||||
...common(true),
|
||||
...console,
|
||||
...dialogs(true),
|
||||
...files(true),
|
||||
...install,
|
||||
...keyboard(true),
|
||||
...navigate(true),
|
||||
...network,
|
||||
...pdf,
|
||||
...snapshot,
|
||||
...tabs(true),
|
||||
...testing,
|
||||
];
|
||||
|
||||
const screenshotTools: Tool<any>[] = [
|
||||
...common(false),
|
||||
...console,
|
||||
...dialogs(false),
|
||||
...files(false),
|
||||
...install,
|
||||
...keyboard(false),
|
||||
...navigate(false),
|
||||
...network,
|
||||
...pdf,
|
||||
...screen,
|
||||
...tabs(false),
|
||||
...testing,
|
||||
];
|
||||
|
||||
import packageJSON from '../package.json' with { type: '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);
|
||||
export async function createConnection(userConfig: Config = {}, contextGetter?: () => Promise<BrowserContext>): Promise<Connection> {
|
||||
const config = await resolveConfig(userConfig);
|
||||
const factory = contextGetter ? new SimpleBrowserContextFactory(contextGetter) : contextFactory(config.browser);
|
||||
return createConnectionImpl(config, factory);
|
||||
}
|
||||
|
||||
class SimpleBrowserContextFactory implements BrowserContextFactory {
|
||||
private readonly _contextGetter: () => Promise<BrowserContext>;
|
||||
|
||||
constructor(contextGetter: () => Promise<BrowserContext>) {
|
||||
this._contextGetter = contextGetter;
|
||||
}
|
||||
|
||||
async createContext(): Promise<{ browserContext: BrowserContext, close: () => Promise<void> }> {
|
||||
const browserContext = await this._contextGetter();
|
||||
return {
|
||||
browserContext,
|
||||
close: () => browserContext.close()
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
22
src/package.ts
Normal file
22
src/package.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
/**
|
||||
* Copyright (c) Microsoft Corporation.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import fs from 'node:fs';
|
||||
import url from 'node:url';
|
||||
import path from 'node:path';
|
||||
|
||||
const __filename = url.fileURLToPath(import.meta.url);
|
||||
export const packageJSON = JSON.parse(fs.readFileSync(path.join(path.dirname(__filename), '..', 'package.json'), 'utf8'));
|
||||
@@ -15,20 +15,23 @@
|
||||
*/
|
||||
|
||||
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 {
|
||||
private _frameLocators: PageOrFrameLocator[] = [];
|
||||
private _page: playwright.Page;
|
||||
private _text!: string;
|
||||
|
||||
constructor() {
|
||||
constructor(page: playwright.Page) {
|
||||
this._page = page;
|
||||
}
|
||||
|
||||
static async create(page: playwright.Page): Promise<PageSnapshot> {
|
||||
const snapshot = new PageSnapshot();
|
||||
await snapshot._build(page);
|
||||
const snapshot = new PageSnapshot(page);
|
||||
await snapshot._build();
|
||||
return snapshot;
|
||||
}
|
||||
|
||||
@@ -36,66 +39,17 @@ export class PageSnapshot {
|
||||
return this._text;
|
||||
}
|
||||
|
||||
private async _build(page: playwright.Page) {
|
||||
const yamlDocument = await this._snapshotFrame(page);
|
||||
private async _build() {
|
||||
const snapshot = await callOnPageNoTrace(this._page, page => (page as PageEx)._snapshotForAI());
|
||||
this._text = [
|
||||
`- Page Snapshot`,
|
||||
'```yaml',
|
||||
yamlDocument.toString({ indentSeq: false }).trim(),
|
||||
snapshot,
|
||||
'```',
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
private async _snapshotFrame(frame: playwright.Page | playwright.FrameLocator) {
|
||||
const frameIndex = this._frameLocators.push(frame) - 1;
|
||||
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}`);
|
||||
refLocator(params: { element: string, ref: string }): playwright.Locator {
|
||||
return this._page.locator(`aria-ref=${params.ref}`).describe(params.element);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,51 +15,63 @@
|
||||
*/
|
||||
|
||||
import { program } from 'commander';
|
||||
|
||||
import { createServer } from './index.js';
|
||||
import { ServerList } from './server.js';
|
||||
// @ts-ignore
|
||||
import { startTraceViewerServer } from 'playwright-core/lib/server';
|
||||
|
||||
import { startHttpTransport, startStdioTransport } from './transport.js';
|
||||
|
||||
import { resolveConfig } from './config.js';
|
||||
|
||||
import packageJSON from '../package.json' with { type: 'json' };
|
||||
import { resolveCLIConfig } from './config.js';
|
||||
import { Server } from './server.js';
|
||||
import { packageJSON } from './package.js';
|
||||
|
||||
program
|
||||
.version('Version ' + packageJSON.version)
|
||||
.name(packageJSON.name)
|
||||
.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('--allowed-origins <origins>', 'semicolon-separated list of origins to allow the browser to request. Default is to allow all.', semicolonSeparatedList)
|
||||
.option('--blocked-origins <origins>', 'semicolon-separated list of origins to block the browser from requesting. Blocklist is evaluated before allowlist. If used without the allowlist, requests not matching the blocklist are still allowed.', semicolonSeparatedList)
|
||||
.option('--block-service-workers', 'block service workers')
|
||||
.option('--browser <browser>', 'browser or chrome channel to use, possible values: chrome, firefox, webkit, msedge.')
|
||||
.option('--caps <caps>', 'comma-separated list of capabilities to enable, possible values: tabs, pdf, history, wait, files, install. Default is all.')
|
||||
.option('--cdp-endpoint <endpoint>', 'CDP endpoint to connect to.')
|
||||
.option('--executable-path <path>', 'Path to the browser executable.')
|
||||
.option('--headless', 'Run browser in headless mode, headed by default')
|
||||
.option('--device <device>', 'Device to emulate, for example: "iPhone 15"')
|
||||
.option('--user-data-dir <path>', 'Path to the user data directory')
|
||||
.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('--config <path>', 'path to the configuration file.')
|
||||
.option('--device <device>', 'device to emulate, for example: "iPhone 15"')
|
||||
.option('--executable-path <path>', 'path to the browser executable.')
|
||||
.option('--headless', 'run browser in headless mode, headed by default')
|
||||
.option('--host <host>', 'host to bind server to. Default is localhost. Use 0.0.0.0 to bind to all interfaces.')
|
||||
.option('--ignore-https-errors', 'ignore https errors')
|
||||
.option('--isolated', 'keep the browser profile in memory, do not save it to disk.')
|
||||
.option('--image-responses <mode>', 'whether to send image responses to the client. Can be "allow", "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('--config <path>', 'Path to the configuration file.')
|
||||
.action(async options => {
|
||||
const config = await resolveConfig(options);
|
||||
const serverList = new ServerList(() => createServer(config));
|
||||
setupExitWatchdog(serverList);
|
||||
const config = await resolveCLIConfig(options);
|
||||
const server = new Server(config);
|
||||
server.setupExitWatchdog();
|
||||
|
||||
if (options.port)
|
||||
startHttpTransport(+options.port, options.host, serverList);
|
||||
if (config.server.port !== undefined)
|
||||
startHttpTransport(server);
|
||||
else
|
||||
await startStdioTransport(serverList);
|
||||
await startStdioTransport(server);
|
||||
|
||||
if (config.saveTrace) {
|
||||
const server = await startTraceViewerServer();
|
||||
const urlPrefix = server.urlPrefix('human-readable');
|
||||
const url = urlPrefix + '/trace/index.html?trace=' + config.browser.launchOptions.tracesDir + '/trace.json';
|
||||
// eslint-disable-next-line no-console
|
||||
console.error('\nTrace viewer listening on ' + url);
|
||||
}
|
||||
});
|
||||
|
||||
function setupExitWatchdog(serverList: ServerList) {
|
||||
const handleExit = async () => {
|
||||
setTimeout(() => process.exit(0), 15000);
|
||||
await serverList.closeAll();
|
||||
process.exit(0);
|
||||
};
|
||||
|
||||
process.stdin.on('close', handleExit);
|
||||
process.on('SIGINT', handleExit);
|
||||
process.on('SIGTERM', handleExit);
|
||||
function semicolonSeparatedList(value: string): string[] {
|
||||
return value.split(';').map(v => v.trim());
|
||||
}
|
||||
|
||||
program.parse(process.argv);
|
||||
void program.parseAsync(process.argv);
|
||||
|
||||
121
src/server.ts
121
src/server.ts
@@ -14,95 +14,46 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { Server } from '@modelcontextprotocol/sdk/server/index.js';
|
||||
import { CallToolRequestSchema, ListToolsRequestSchema } from '@modelcontextprotocol/sdk/types.js';
|
||||
import { zodToJsonSchema } from 'zod-to-json-schema';
|
||||
import { createConnection } from './connection.js';
|
||||
import { contextFactory } from './browserContextFactory.js';
|
||||
|
||||
import { Context } from './context.js';
|
||||
import type { FullConfig } from './config.js';
|
||||
import type { Connection } from './connection.js';
|
||||
import type { Transport } from '@modelcontextprotocol/sdk/shared/transport.js';
|
||||
import type { BrowserContextFactory } from './browserContextFactory.js';
|
||||
|
||||
import type { Tool } from './tools/tool.js';
|
||||
import type { Config } from '../config.js';
|
||||
export class Server {
|
||||
readonly config: FullConfig;
|
||||
private _connectionList: Connection[] = [];
|
||||
private _browserConfig: FullConfig['browser'];
|
||||
private _contextFactory: BrowserContextFactory;
|
||||
|
||||
type MCPServerOptions = {
|
||||
name: string;
|
||||
version: string;
|
||||
tools: Tool[];
|
||||
constructor(config: FullConfig) {
|
||||
this.config = config;
|
||||
this._browserConfig = config.browser;
|
||||
this._contextFactory = contextFactory(this._browserConfig);
|
||||
}
|
||||
|
||||
async createConnection(transport: Transport): Promise<Connection> {
|
||||
const connection = createConnection(this.config, this._contextFactory);
|
||||
this._connectionList.push(connection);
|
||||
await connection.server.connect(transport);
|
||||
return connection;
|
||||
}
|
||||
|
||||
setupExitWatchdog() {
|
||||
let isExiting = false;
|
||||
const handleExit = async () => {
|
||||
if (isExiting)
|
||||
return;
|
||||
isExiting = true;
|
||||
setTimeout(() => process.exit(0), 15000);
|
||||
await Promise.all(this._connectionList.map(connection => connection.close()));
|
||||
process.exit(0);
|
||||
};
|
||||
|
||||
export function createServerWithTools(serverOptions: MCPServerOptions, config: Config): Server {
|
||||
const { name, version, tools } = serverOptions;
|
||||
const context = new Context(tools, config);
|
||||
const server = new Server({ name, version }, {
|
||||
capabilities: {
|
||||
tools: {},
|
||||
}
|
||||
});
|
||||
|
||||
server.setRequestHandler(ListToolsRequestSchema, async () => {
|
||||
return {
|
||||
tools: tools.map(tool => ({
|
||||
name: tool.schema.name,
|
||||
description: tool.schema.description,
|
||||
inputSchema: zodToJsonSchema(tool.schema.inputSchema)
|
||||
})),
|
||||
};
|
||||
});
|
||||
|
||||
server.setRequestHandler(CallToolRequestSchema, async request => {
|
||||
const errorResult = (...messages: string[]) => ({
|
||||
content: [{ type: 'text', text: messages.join('\n') }],
|
||||
isError: true,
|
||||
});
|
||||
const tool = tools.find(tool => tool.schema.name === request.params.name);
|
||||
if (!tool)
|
||||
return errorResult(`Tool "${request.params.name}" not found`);
|
||||
|
||||
|
||||
const modalStates = context.modalStates().map(state => state.type);
|
||||
if (tool.clearsModalState && !modalStates.includes(tool.clearsModalState))
|
||||
return errorResult(`The tool "${request.params.name}" can only be used when there is related modal state present.`, ...context.modalStatesMarkdown());
|
||||
if (!tool.clearsModalState && modalStates.length)
|
||||
return errorResult(`Tool "${request.params.name}" does not handle the modal state.`, ...context.modalStatesMarkdown());
|
||||
|
||||
try {
|
||||
return await context.run(tool, request.params.arguments);
|
||||
} catch (error) {
|
||||
return errorResult(String(error));
|
||||
}
|
||||
});
|
||||
|
||||
const oldClose = server.close.bind(server);
|
||||
|
||||
server.close = async () => {
|
||||
await oldClose();
|
||||
await context.close();
|
||||
};
|
||||
|
||||
return server;
|
||||
}
|
||||
|
||||
export class ServerList {
|
||||
private _servers: Server[] = [];
|
||||
private _serverFactory: () => Promise<Server>;
|
||||
|
||||
constructor(serverFactory: () => Promise<Server>) {
|
||||
this._serverFactory = serverFactory;
|
||||
}
|
||||
|
||||
async create() {
|
||||
const server = await this._serverFactory();
|
||||
this._servers.push(server);
|
||||
return server;
|
||||
}
|
||||
|
||||
async close(server: Server) {
|
||||
const index = this._servers.indexOf(server);
|
||||
if (index !== -1)
|
||||
this._servers.splice(index, 1);
|
||||
await server.close();
|
||||
}
|
||||
|
||||
async closeAll() {
|
||||
await Promise.all(this._servers.map(server => server.close()));
|
||||
process.stdin.on('close', handleExit);
|
||||
process.on('SIGINT', handleExit);
|
||||
process.on('SIGTERM', handleExit);
|
||||
}
|
||||
}
|
||||
|
||||
46
src/tab.ts
46
src/tab.ts
@@ -19,11 +19,12 @@ import * as playwright from 'playwright';
|
||||
import { PageSnapshot } from './pageSnapshot.js';
|
||||
|
||||
import type { Context } from './context.js';
|
||||
import { callOnPageNoTrace } from './tools/utils.js';
|
||||
|
||||
export class Tab {
|
||||
readonly context: Context;
|
||||
readonly page: playwright.Page;
|
||||
private _console: playwright.ConsoleMessage[] = [];
|
||||
private _consoleMessages: playwright.ConsoleMessage[] = [];
|
||||
private _requests: Map<playwright.Request, playwright.Response | null> = new Map();
|
||||
private _snapshot: PageSnapshot | undefined;
|
||||
private _onPageClose: (tab: Tab) => void;
|
||||
@@ -32,13 +33,9 @@ export class Tab {
|
||||
this.context = context;
|
||||
this.page = page;
|
||||
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('response', response => this._requests.set(response.request(), response));
|
||||
page.on('framenavigated', frame => {
|
||||
if (!frame.parentFrame())
|
||||
this._clearCollectedArtifacts();
|
||||
});
|
||||
page.on('close', () => this._onClose());
|
||||
page.on('filechooser', chooser => {
|
||||
this.context.setModalState({
|
||||
@@ -56,7 +53,7 @@ export class Tab {
|
||||
}
|
||||
|
||||
private _clearCollectedArtifacts() {
|
||||
this._console.length = 0;
|
||||
this._consoleMessages.length = 0;
|
||||
this._requests.clear();
|
||||
}
|
||||
|
||||
@@ -65,10 +62,39 @@ export class Tab {
|
||||
this._onPageClose(this);
|
||||
}
|
||||
|
||||
async title(): Promise<string> {
|
||||
return await callOnPageNoTrace(this.page, page => page.title());
|
||||
}
|
||||
|
||||
async waitForLoadState(state: 'load', options?: { timeout?: number }): Promise<void> {
|
||||
await callOnPageNoTrace(this.page, page => page.waitForLoadState(state, options).catch(() => {}));
|
||||
}
|
||||
|
||||
async navigate(url: string) {
|
||||
this._clearCollectedArtifacts();
|
||||
|
||||
const downloadEvent = callOnPageNoTrace(this.page, page => page.waitForEvent('download').catch(() => {}));
|
||||
try {
|
||||
await this.page.goto(url, { waitUntil: 'domcontentloaded' });
|
||||
} catch (_e: unknown) {
|
||||
const e = _e as Error;
|
||||
const mightBeDownload =
|
||||
e.message.includes('net::ERR_ABORTED') // chromium
|
||||
|| e.message.includes('Download is starting'); // firefox + webkit
|
||||
if (!mightBeDownload)
|
||||
throw e;
|
||||
|
||||
// on chromium, the download event is fired *after* page.goto rejects, so we wait a lil bit
|
||||
const download = await Promise.race([
|
||||
downloadEvent,
|
||||
new Promise(resolve => setTimeout(resolve, 500)),
|
||||
]);
|
||||
if (!download)
|
||||
throw e;
|
||||
}
|
||||
|
||||
// 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 {
|
||||
@@ -81,8 +107,8 @@ export class Tab {
|
||||
return this._snapshot;
|
||||
}
|
||||
|
||||
console(): playwright.ConsoleMessage[] {
|
||||
return this._console;
|
||||
consoleMessages(): playwright.ConsoleMessage[] {
|
||||
return this._consoleMessages;
|
||||
}
|
||||
|
||||
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),
|
||||
];
|
||||
@@ -17,40 +17,21 @@
|
||||
import { z } from 'zod';
|
||||
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({
|
||||
capability: 'core',
|
||||
|
||||
schema: {
|
||||
name: 'browser_close',
|
||||
title: 'Close browser',
|
||||
description: 'Close the page',
|
||||
inputSchema: z.object({}),
|
||||
type: 'readOnly',
|
||||
},
|
||||
|
||||
handle: async context => {
|
||||
await context.close();
|
||||
return {
|
||||
code: [`// Internal to close the page`],
|
||||
code: [`await page.close()`],
|
||||
captureSnapshot: false,
|
||||
waitForNetwork: false,
|
||||
};
|
||||
@@ -61,11 +42,13 @@ const resize: ToolFactory = captureSnapshot => defineTool({
|
||||
capability: 'core',
|
||||
schema: {
|
||||
name: 'browser_resize',
|
||||
title: 'Resize browser window',
|
||||
description: 'Resize the browser window',
|
||||
inputSchema: z.object({
|
||||
width: z.number().describe('Width of the browser window'),
|
||||
height: z.number().describe('Height of the browser window'),
|
||||
}),
|
||||
type: 'readOnly',
|
||||
},
|
||||
|
||||
handle: async (context, params) => {
|
||||
@@ -91,6 +74,5 @@ const resize: ToolFactory = captureSnapshot => defineTool({
|
||||
|
||||
export default (captureSnapshot: boolean) => [
|
||||
close,
|
||||
wait(captureSnapshot),
|
||||
resize(captureSnapshot)
|
||||
];
|
||||
|
||||
@@ -21,11 +21,13 @@ const console = defineTool({
|
||||
capability: 'core',
|
||||
schema: {
|
||||
name: 'browser_console_messages',
|
||||
title: 'Get console messages',
|
||||
description: 'Returns all console messages',
|
||||
inputSchema: z.object({}),
|
||||
type: 'readOnly',
|
||||
},
|
||||
handle: async context => {
|
||||
const messages = context.currentTabOrDie().console();
|
||||
const messages = context.currentTabOrDie().consoleMessages();
|
||||
const log = messages.map(message => `[${message.type().toUpperCase()}] ${message.text()}`).join('\n');
|
||||
return {
|
||||
code: [`// <internal code to get console messages>`],
|
||||
|
||||
@@ -22,11 +22,13 @@ const handleDialog: ToolFactory = captureSnapshot => defineTool({
|
||||
|
||||
schema: {
|
||||
name: 'browser_handle_dialog',
|
||||
title: 'Handle a dialog',
|
||||
description: 'Handle a dialog',
|
||||
inputSchema: z.object({
|
||||
accept: z.boolean().describe('Whether to accept the dialog.'),
|
||||
promptText: z.string().optional().describe('The text of the prompt in case of a prompt dialog.'),
|
||||
}),
|
||||
type: 'destructive',
|
||||
},
|
||||
|
||||
handle: async (context, params) => {
|
||||
|
||||
@@ -22,10 +22,12 @@ const uploadFile: ToolFactory = captureSnapshot => defineTool({
|
||||
|
||||
schema: {
|
||||
name: 'browser_file_upload',
|
||||
title: 'Upload files',
|
||||
description: 'Upload one or multiple files',
|
||||
inputSchema: z.object({
|
||||
paths: z.array(z.string()).describe('The absolute paths to the files to upload. Can be a single file or multiple files.'),
|
||||
}),
|
||||
type: 'destructive',
|
||||
},
|
||||
|
||||
handle: async (context, params) => {
|
||||
|
||||
@@ -20,21 +20,23 @@ import path from 'path';
|
||||
import { z } from 'zod';
|
||||
import { defineTool } from './tool.js';
|
||||
|
||||
import { createRequire } from 'node:module';
|
||||
const require = createRequire(import.meta.url);
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const install = defineTool({
|
||||
capability: 'install',
|
||||
schema: {
|
||||
name: 'browser_install',
|
||||
title: 'Install the browser specified in the config',
|
||||
description: 'Install the browser specified in the config. Call this if you get an error about the browser not being installed.',
|
||||
inputSchema: z.object({}),
|
||||
type: 'destructive',
|
||||
},
|
||||
|
||||
handle: async context => {
|
||||
const channel = context.config.browser?.launchOptions?.channel ?? context.config.browser?.launchOptions.browserName ?? 'chrome';
|
||||
const cli = path.join(require.resolve('playwright/package.json'), '..', 'cli.js');
|
||||
const child = fork(cli, ['install', channel], {
|
||||
const channel = context.config.browser?.launchOptions?.channel ?? context.config.browser?.browserName ?? 'chrome';
|
||||
const cliUrl = import.meta.resolve('playwright/package.json');
|
||||
const cliPath = path.join(fileURLToPath(cliUrl), '..', 'cli.js');
|
||||
const child = fork(cliPath, ['install', channel], {
|
||||
stdio: 'pipe',
|
||||
});
|
||||
const output: string[] = [];
|
||||
|
||||
@@ -22,10 +22,12 @@ const pressKey: ToolFactory = captureSnapshot => defineTool({
|
||||
|
||||
schema: {
|
||||
name: 'browser_press_key',
|
||||
title: 'Press a key',
|
||||
description: 'Press a key on the keyboard',
|
||||
inputSchema: z.object({
|
||||
key: z.string().describe('Name of the key to press or a character to generate, such as `ArrowLeft` or `a`'),
|
||||
}),
|
||||
type: 'destructive',
|
||||
},
|
||||
|
||||
handle: async (context, params) => {
|
||||
|
||||
@@ -22,10 +22,12 @@ const navigate: ToolFactory = captureSnapshot => defineTool({
|
||||
|
||||
schema: {
|
||||
name: 'browser_navigate',
|
||||
title: 'Navigate to a URL',
|
||||
description: 'Navigate to a URL',
|
||||
inputSchema: z.object({
|
||||
url: z.string().describe('The URL to navigate to'),
|
||||
}),
|
||||
type: 'destructive',
|
||||
},
|
||||
|
||||
handle: async (context, params) => {
|
||||
@@ -49,8 +51,10 @@ const goBack: ToolFactory = captureSnapshot => defineTool({
|
||||
capability: 'history',
|
||||
schema: {
|
||||
name: 'browser_navigate_back',
|
||||
title: 'Go back',
|
||||
description: 'Go back to the previous page',
|
||||
inputSchema: z.object({}),
|
||||
type: 'readOnly',
|
||||
},
|
||||
|
||||
handle: async context => {
|
||||
@@ -73,8 +77,10 @@ const goForward: ToolFactory = captureSnapshot => defineTool({
|
||||
capability: 'history',
|
||||
schema: {
|
||||
name: 'browser_navigate_forward',
|
||||
title: 'Go forward',
|
||||
description: 'Go forward to the next page',
|
||||
inputSchema: z.object({}),
|
||||
type: 'readOnly',
|
||||
},
|
||||
handle: async context => {
|
||||
const tab = context.currentTabOrDie();
|
||||
|
||||
@@ -24,8 +24,10 @@ const requests = defineTool({
|
||||
|
||||
schema: {
|
||||
name: 'browser_network_requests',
|
||||
title: 'List network requests',
|
||||
description: 'Returns all network requests since loading the page',
|
||||
inputSchema: z.object({}),
|
||||
type: 'readOnly',
|
||||
},
|
||||
|
||||
handle: async context => {
|
||||
|
||||
@@ -20,18 +20,24 @@ import { defineTool } from './tool.js';
|
||||
import * as javascript from '../javascript.js';
|
||||
import { outputFile } from '../config.js';
|
||||
|
||||
const pdfSchema = z.object({
|
||||
filename: z.string().optional().describe('File name to save the pdf to. Defaults to `page-{timestamp}.pdf` if not specified.'),
|
||||
});
|
||||
|
||||
const pdf = defineTool({
|
||||
capability: 'pdf',
|
||||
|
||||
schema: {
|
||||
name: 'browser_pdf_save',
|
||||
title: 'Save as PDF',
|
||||
description: 'Save page as PDF',
|
||||
inputSchema: z.object({}),
|
||||
inputSchema: pdfSchema,
|
||||
type: 'readOnly',
|
||||
},
|
||||
|
||||
handle: async context => {
|
||||
handle: async (context, params) => {
|
||||
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 = [
|
||||
`// 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,
|
||||
];
|
||||
@@ -18,16 +18,16 @@ import { z } from 'zod';
|
||||
|
||||
import { defineTool } from './tool.js';
|
||||
import * as javascript from '../javascript.js';
|
||||
import { outputFile } from '../config.js';
|
||||
|
||||
import type * as playwright from 'playwright';
|
||||
import { generateLocator } from './utils.js';
|
||||
|
||||
const snapshot = defineTool({
|
||||
capability: 'core',
|
||||
schema: {
|
||||
name: 'browser_snapshot',
|
||||
title: 'Page snapshot',
|
||||
description: 'Capture accessibility snapshot of the current page, this is better than screenshot',
|
||||
inputSchema: z.object({}),
|
||||
type: 'readOnly',
|
||||
},
|
||||
|
||||
handle: async context => {
|
||||
@@ -50,13 +50,15 @@ const click = defineTool({
|
||||
capability: 'core',
|
||||
schema: {
|
||||
name: 'browser_click',
|
||||
title: 'Click',
|
||||
description: 'Perform click on a web page',
|
||||
inputSchema: elementSchema,
|
||||
type: 'destructive',
|
||||
},
|
||||
|
||||
handle: async (context, params) => {
|
||||
const tab = context.currentTabOrDie();
|
||||
const locator = tab.snapshotOrDie().refLocator(params.ref);
|
||||
const locator = tab.snapshotOrDie().refLocator(params);
|
||||
|
||||
const code = [
|
||||
`// Click ${params.element}`,
|
||||
@@ -76,6 +78,7 @@ const drag = defineTool({
|
||||
capability: 'core',
|
||||
schema: {
|
||||
name: 'browser_drag',
|
||||
title: 'Drag mouse',
|
||||
description: 'Perform drag and drop between two elements',
|
||||
inputSchema: z.object({
|
||||
startElement: z.string().describe('Human-readable source element description used to obtain the permission to interact with the element'),
|
||||
@@ -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'),
|
||||
endRef: z.string().describe('Exact target element reference from the page snapshot'),
|
||||
}),
|
||||
type: 'destructive',
|
||||
},
|
||||
|
||||
handle: async (context, params) => {
|
||||
const snapshot = context.currentTabOrDie().snapshotOrDie();
|
||||
const startLocator = snapshot.refLocator(params.startRef);
|
||||
const endLocator = snapshot.refLocator(params.endRef);
|
||||
const startLocator = snapshot.refLocator({ ref: params.startRef, element: params.startElement });
|
||||
const endLocator = snapshot.refLocator({ ref: params.endRef, element: params.endElement });
|
||||
|
||||
const code = [
|
||||
`// Drag ${params.startElement} to ${params.endElement}`,
|
||||
@@ -108,13 +112,15 @@ const hover = defineTool({
|
||||
capability: 'core',
|
||||
schema: {
|
||||
name: 'browser_hover',
|
||||
title: 'Hover mouse',
|
||||
description: 'Hover over element on page',
|
||||
inputSchema: elementSchema,
|
||||
type: 'readOnly',
|
||||
},
|
||||
|
||||
handle: async (context, params) => {
|
||||
const snapshot = context.currentTabOrDie().snapshotOrDie();
|
||||
const locator = snapshot.refLocator(params.ref);
|
||||
const locator = snapshot.refLocator(params);
|
||||
|
||||
const code = [
|
||||
`// Hover over ${params.element}`,
|
||||
@@ -140,13 +146,15 @@ const type = defineTool({
|
||||
capability: 'core',
|
||||
schema: {
|
||||
name: 'browser_type',
|
||||
title: 'Type text',
|
||||
description: 'Type text into editable element',
|
||||
inputSchema: typeSchema,
|
||||
type: 'destructive',
|
||||
},
|
||||
|
||||
handle: async (context, params) => {
|
||||
const snapshot = context.currentTabOrDie().snapshotOrDie();
|
||||
const locator = snapshot.refLocator(params.ref);
|
||||
const locator = snapshot.refLocator(params);
|
||||
|
||||
const code: string[] = [];
|
||||
const steps: (() => Promise<void>)[] = [];
|
||||
@@ -184,13 +192,15 @@ const selectOption = defineTool({
|
||||
capability: 'core',
|
||||
schema: {
|
||||
name: 'browser_select_option',
|
||||
title: 'Select option',
|
||||
description: 'Select an option in a dropdown',
|
||||
inputSchema: selectOptionSchema,
|
||||
type: 'destructive',
|
||||
},
|
||||
|
||||
handle: async (context, params) => {
|
||||
const snapshot = context.currentTabOrDie().snapshotOrDie();
|
||||
const locator = snapshot.refLocator(params.ref);
|
||||
const locator = snapshot.refLocator(params);
|
||||
|
||||
const code = [
|
||||
`// 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 [
|
||||
snapshot,
|
||||
click,
|
||||
@@ -276,5 +223,4 @@ export default [
|
||||
hover,
|
||||
type,
|
||||
selectOption,
|
||||
screenshot,
|
||||
];
|
||||
|
||||
@@ -22,8 +22,10 @@ const listTabs = defineTool({
|
||||
|
||||
schema: {
|
||||
name: 'browser_tab_list',
|
||||
title: 'List tabs',
|
||||
description: 'List browser tabs',
|
||||
inputSchema: z.object({}),
|
||||
type: 'readOnly',
|
||||
},
|
||||
|
||||
handle: async context => {
|
||||
@@ -47,10 +49,12 @@ const selectTab: ToolFactory = captureSnapshot => defineTool({
|
||||
|
||||
schema: {
|
||||
name: 'browser_tab_select',
|
||||
title: 'Select a tab',
|
||||
description: 'Select a tab by index',
|
||||
inputSchema: z.object({
|
||||
index: z.number().describe('The index of the tab to select'),
|
||||
}),
|
||||
type: 'readOnly',
|
||||
},
|
||||
|
||||
handle: async (context, params) => {
|
||||
@@ -72,10 +76,12 @@ const newTab: ToolFactory = captureSnapshot => defineTool({
|
||||
|
||||
schema: {
|
||||
name: 'browser_tab_new',
|
||||
title: 'Open a new tab',
|
||||
description: 'Open a new tab',
|
||||
inputSchema: z.object({
|
||||
url: z.string().optional().describe('The URL to navigate to in the new tab. If not provided, the new tab will be blank.'),
|
||||
}),
|
||||
type: 'readOnly',
|
||||
},
|
||||
|
||||
handle: async (context, params) => {
|
||||
@@ -99,10 +105,12 @@ const closeTab: ToolFactory = captureSnapshot => defineTool({
|
||||
|
||||
schema: {
|
||||
name: 'browser_tab_close',
|
||||
title: 'Close a tab',
|
||||
description: 'Close a tab',
|
||||
inputSchema: z.object({
|
||||
index: z.number().optional().describe('The index of the tab to close. Closes current tab if not provided.'),
|
||||
}),
|
||||
type: 'destructive',
|
||||
},
|
||||
|
||||
handle: async (context, params) => {
|
||||
|
||||
@@ -28,8 +28,10 @@ const generateTest = defineTool({
|
||||
|
||||
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) => {
|
||||
|
||||
@@ -22,8 +22,10 @@ import type { ToolCapability } from '../../config.js';
|
||||
|
||||
export type ToolSchema<Input extends InputType> = {
|
||||
name: string;
|
||||
title: string;
|
||||
description: string;
|
||||
inputSchema: Input;
|
||||
type: 'readOnly' | 'destructive';
|
||||
};
|
||||
|
||||
type InputType = z.Schema;
|
||||
|
||||
@@ -16,8 +16,9 @@
|
||||
|
||||
import type * as playwright from 'playwright';
|
||||
import type { Context } from '../context.js';
|
||||
import type { Tab } from '../tab.js';
|
||||
|
||||
export async function waitForCompletion<R>(context: Context, 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>();
|
||||
let frameNavigated = false;
|
||||
let waitCallback: () => void = () => {};
|
||||
@@ -36,9 +37,7 @@ export async function waitForCompletion<R>(context: Context, page: playwright.Pa
|
||||
frameNavigated = true;
|
||||
dispose();
|
||||
clearTimeout(timeout);
|
||||
void frame.waitForLoadState('load').then(() => {
|
||||
waitCallback();
|
||||
});
|
||||
void tab.waitForLoadState('load').then(waitCallback);
|
||||
};
|
||||
|
||||
const onTimeout = () => {
|
||||
@@ -46,15 +45,15 @@ export async function waitForCompletion<R>(context: Context, page: playwright.Pa
|
||||
waitCallback();
|
||||
};
|
||||
|
||||
page.on('request', requestListener);
|
||||
page.on('requestfinished', requestFinishedListener);
|
||||
page.on('framenavigated', frameNavigateListener);
|
||||
tab.page.on('request', requestListener);
|
||||
tab.page.on('requestfinished', requestFinishedListener);
|
||||
tab.page.on('framenavigated', frameNavigateListener);
|
||||
const timeout = setTimeout(onTimeout, 10000);
|
||||
|
||||
const dispose = () => {
|
||||
page.off('request', requestListener);
|
||||
page.off('requestfinished', requestFinishedListener);
|
||||
page.off('framenavigated', frameNavigateListener);
|
||||
tab.page.off('request', requestListener);
|
||||
tab.page.off('requestfinished', requestFinishedListener);
|
||||
tab.page.off('framenavigated', frameNavigateListener);
|
||||
clearTimeout(timeout);
|
||||
};
|
||||
|
||||
@@ -77,3 +76,11 @@ export function sanitizeForFilePath(s: string) {
|
||||
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 });
|
||||
}
|
||||
|
||||
@@ -27,8 +27,10 @@ const screenshot = defineTool({
|
||||
capability: 'core',
|
||||
schema: {
|
||||
name: 'browser_screen_capture',
|
||||
title: 'Take a screenshot',
|
||||
description: 'Take a screenshot of the current page',
|
||||
inputSchema: z.object({}),
|
||||
type: 'readOnly',
|
||||
},
|
||||
|
||||
handle: async context => {
|
||||
@@ -59,11 +61,13 @@ const moveMouse = defineTool({
|
||||
capability: 'core',
|
||||
schema: {
|
||||
name: 'browser_screen_move_mouse',
|
||||
title: 'Move mouse',
|
||||
description: 'Move mouse to a given position',
|
||||
inputSchema: elementSchema.extend({
|
||||
x: z.number().describe('X coordinate'),
|
||||
y: z.number().describe('Y coordinate'),
|
||||
}),
|
||||
type: 'readOnly',
|
||||
},
|
||||
|
||||
handle: async (context, params) => {
|
||||
@@ -86,11 +90,13 @@ const click = defineTool({
|
||||
capability: 'core',
|
||||
schema: {
|
||||
name: 'browser_screen_click',
|
||||
title: 'Click',
|
||||
description: 'Click left mouse button',
|
||||
inputSchema: elementSchema.extend({
|
||||
x: z.number().describe('X coordinate'),
|
||||
y: z.number().describe('Y coordinate'),
|
||||
}),
|
||||
type: 'destructive',
|
||||
},
|
||||
|
||||
handle: async (context, params) => {
|
||||
@@ -117,9 +123,9 @@ const click = defineTool({
|
||||
|
||||
const drag = defineTool({
|
||||
capability: 'core',
|
||||
|
||||
schema: {
|
||||
name: 'browser_screen_drag',
|
||||
title: 'Drag mouse',
|
||||
description: 'Drag left mouse button',
|
||||
inputSchema: elementSchema.extend({
|
||||
startX: z.number().describe('Start X coordinate'),
|
||||
@@ -127,6 +133,7 @@ const drag = defineTool({
|
||||
endX: z.number().describe('End X coordinate'),
|
||||
endY: z.number().describe('End Y coordinate'),
|
||||
}),
|
||||
type: 'destructive',
|
||||
},
|
||||
|
||||
handle: async (context, params) => {
|
||||
@@ -158,14 +165,15 @@ const drag = defineTool({
|
||||
|
||||
const type = defineTool({
|
||||
capability: 'core',
|
||||
|
||||
schema: {
|
||||
name: 'browser_screen_type',
|
||||
title: 'Type text',
|
||||
description: 'Type text',
|
||||
inputSchema: z.object({
|
||||
text: z.string().describe('Text to type into the element'),
|
||||
submit: z.boolean().optional().describe('Whether to submit entered text (press Enter after)'),
|
||||
}),
|
||||
type: 'destructive',
|
||||
},
|
||||
|
||||
handle: async (context, params) => {
|
||||
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,20 @@ import http from 'node:http';
|
||||
import assert from 'node:assert';
|
||||
import crypto from 'node:crypto';
|
||||
|
||||
import { ServerList } from './server.js';
|
||||
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
||||
import debug from 'debug';
|
||||
import { SSEServerTransport } from '@modelcontextprotocol/sdk/server/sse.js';
|
||||
import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
|
||||
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
||||
|
||||
export async function startStdioTransport(serverList: ServerList) {
|
||||
const server = await serverList.create();
|
||||
await server.connect(new StdioServerTransport());
|
||||
import type { Server } from './server.js';
|
||||
|
||||
export async function startStdioTransport(server: Server) {
|
||||
await server.createConnection(new StdioServerTransport());
|
||||
}
|
||||
|
||||
async function handleSSE(req: http.IncomingMessage, res: http.ServerResponse, url: URL, serverList: ServerList, sessions: Map<string, SSEServerTransport>) {
|
||||
const testDebug = debug('pw:mcp:test');
|
||||
|
||||
async function handleSSE(server: Server, req: http.IncomingMessage, res: http.ServerResponse, url: URL, sessions: Map<string, SSEServerTransport>) {
|
||||
if (req.method === 'POST') {
|
||||
const sessionId = url.searchParams.get('sessionId');
|
||||
if (!sessionId) {
|
||||
@@ -46,22 +49,22 @@ async function handleSSE(req: http.IncomingMessage, res: http.ServerResponse, ur
|
||||
} else if (req.method === 'GET') {
|
||||
const transport = new SSEServerTransport('/sse', res);
|
||||
sessions.set(transport.sessionId, transport);
|
||||
const server = await serverList.create();
|
||||
testDebug(`create SSE session: ${transport.sessionId}`);
|
||||
const connection = await server.createConnection(transport);
|
||||
res.on('close', () => {
|
||||
testDebug(`delete SSE session: ${transport.sessionId}`);
|
||||
sessions.delete(transport.sessionId);
|
||||
serverList.close(server).catch(e => {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error(e);
|
||||
void connection.close().catch(e => console.error(e));
|
||||
});
|
||||
});
|
||||
return await server.connect(transport);
|
||||
return;
|
||||
}
|
||||
|
||||
res.statusCode = 405;
|
||||
res.end('Method not allowed');
|
||||
}
|
||||
|
||||
async function handleStreamable(req: http.IncomingMessage, res: http.ServerResponse, serverList: ServerList, sessions: Map<string, StreamableHTTPServerTransport>) {
|
||||
async function handleStreamable(server: Server, req: http.IncomingMessage, res: http.ServerResponse, sessions: Map<string, StreamableHTTPServerTransport>) {
|
||||
const sessionId = req.headers['mcp-session-id'] as string | undefined;
|
||||
if (sessionId) {
|
||||
const transport = sessions.get(sessionId);
|
||||
@@ -84,26 +87,27 @@ async function handleStreamable(req: http.IncomingMessage, res: http.ServerRespo
|
||||
if (transport.sessionId)
|
||||
sessions.delete(transport.sessionId);
|
||||
};
|
||||
const server = await serverList.create();
|
||||
await server.connect(transport);
|
||||
return await transport.handleRequest(req, res);
|
||||
await server.createConnection(transport);
|
||||
await transport.handleRequest(req, res);
|
||||
return;
|
||||
}
|
||||
|
||||
res.statusCode = 400;
|
||||
res.end('Invalid request');
|
||||
}
|
||||
|
||||
export function startHttpTransport(port: number, hostname: string | undefined, serverList: ServerList) {
|
||||
export function startHttpTransport(server: Server) {
|
||||
const sseSessions = new Map<string, SSEServerTransport>();
|
||||
const streamableSessions = new Map<string, StreamableHTTPServerTransport>();
|
||||
const httpServer = http.createServer(async (req, res) => {
|
||||
const url = new URL(`http://localhost${req.url}`);
|
||||
if (url.pathname.startsWith('/mcp'))
|
||||
await handleStreamable(req, res, serverList, streamableSessions);
|
||||
await handleStreamable(server, req, res, streamableSessions);
|
||||
else
|
||||
await handleSSE(req, res, url, serverList, sseSessions);
|
||||
await handleSSE(server, req, res, url, sseSessions);
|
||||
});
|
||||
httpServer.listen(port, hostname, () => {
|
||||
const { host, port } = server.config.server;
|
||||
httpServer.listen(port, host, () => {
|
||||
const address = httpServer.address();
|
||||
assert(address, 'Could not bind server socket');
|
||||
let url: string;
|
||||
@@ -129,6 +133,6 @@ export function startHttpTransport(port: number, hostname: string | undefined, s
|
||||
'If your client supports streamable HTTP, you can use the /mcp endpoint instead.',
|
||||
].join('\n');
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(message);
|
||||
console.error(message);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -43,7 +43,7 @@ test('test snapshot tool list', async ({ client }) => {
|
||||
'browser_tab_new',
|
||||
'browser_tab_select',
|
||||
'browser_take_screenshot',
|
||||
'browser_wait',
|
||||
'browser_wait_for',
|
||||
]));
|
||||
});
|
||||
|
||||
@@ -72,12 +72,12 @@ test('test vision tool list', async ({ visionClient }) => {
|
||||
'browser_tab_list',
|
||||
'browser_tab_new',
|
||||
'browser_tab_select',
|
||||
'browser_wait',
|
||||
'browser_wait_for',
|
||||
]));
|
||||
});
|
||||
|
||||
test('test capabilities', async ({ startClient }) => {
|
||||
const client = await startClient({
|
||||
const { client } = await startClient({
|
||||
args: ['--caps="core"'],
|
||||
});
|
||||
const { tools } = await client.listTools();
|
||||
|
||||
@@ -16,18 +16,21 @@
|
||||
|
||||
import { test, expect } from './fixtures.js';
|
||||
|
||||
test('cdp server', async ({ cdpEndpoint, startClient }) => {
|
||||
const client = await startClient({ args: [`--cdp-endpoint=${await cdpEndpoint()}`] });
|
||||
test('cdp server', async ({ cdpServer, startClient, server }) => {
|
||||
await cdpServer.start();
|
||||
const { client } = await startClient({ args: [`--cdp-endpoint=${cdpServer.endpoint}`] });
|
||||
expect(await client.callTool({
|
||||
name: 'browser_navigate',
|
||||
arguments: {
|
||||
url: 'data:text/html,<html><title>Title</title><body>Hello, world!</body></html>',
|
||||
},
|
||||
})).toContainTextContent(`- generic [ref=s1e2]: Hello, world!`);
|
||||
arguments: { url: server.HELLO_WORLD },
|
||||
})).toContainTextContent(`- generic [ref=e1]: Hello, world!`);
|
||||
});
|
||||
|
||||
test('cdp server reuse tab', async ({ cdpEndpoint, startClient }) => {
|
||||
const client = await startClient({ args: [`--cdp-endpoint=${await cdpEndpoint()}`] });
|
||||
test('cdp server reuse tab', async ({ cdpServer, startClient, server }) => {
|
||||
const browserContext = await cdpServer.start();
|
||||
const { client } = await startClient({ args: [`--cdp-endpoint=${cdpServer.endpoint}`] });
|
||||
|
||||
const [page] = browserContext.pages();
|
||||
await page.goto(server.HELLO_WORLD);
|
||||
|
||||
expect(await client.callTool({
|
||||
name: 'browser_click',
|
||||
@@ -39,36 +42,36 @@ test('cdp server reuse tab', async ({ cdpEndpoint, startClient }) => {
|
||||
|
||||
expect(await client.callTool({
|
||||
name: 'browser_snapshot',
|
||||
arguments: {},
|
||||
})).toHaveTextContent(`
|
||||
- Ran Playwright code:
|
||||
\`\`\`js
|
||||
// <internal code to capture accessibility snapshot>
|
||||
\`\`\`
|
||||
|
||||
- Page URL: data:text/html,hello world
|
||||
- Page Title:
|
||||
- Page URL: ${server.HELLO_WORLD}
|
||||
- Page Title: Title
|
||||
- Page Snapshot
|
||||
\`\`\`yaml
|
||||
- generic [ref=s1e2]: hello world
|
||||
- generic [ref=e1]: Hello, world!
|
||||
\`\`\`
|
||||
`);
|
||||
});
|
||||
|
||||
test('should throw connection error and allow re-connecting', async ({ cdpEndpoint, startClient }) => {
|
||||
const port = 3200 + test.info().parallelIndex;
|
||||
const client = await startClient({ args: [`--cdp-endpoint=http://localhost:${port}`] });
|
||||
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: 'data:text/html,<html><title>Title</title><body>Hello, world!</body></html>',
|
||||
},
|
||||
arguments: { url: server.PREFIX },
|
||||
})).toContainTextContent(`Error: browserType.connectOverCDP: connect ECONNREFUSED`);
|
||||
await cdpEndpoint(port);
|
||||
await cdpServer.start();
|
||||
expect(await client.callTool({
|
||||
name: 'browser_navigate',
|
||||
arguments: {
|
||||
url: 'data:text/html,<html><title>Title</title><body>Hello, world!</body></html>',
|
||||
},
|
||||
})).toContainTextContent(`- generic [ref=s1e2]: Hello, world!`);
|
||||
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`);
|
||||
});
|
||||
});
|
||||
@@ -16,17 +16,26 @@
|
||||
|
||||
import { test, expect } from './fixtures.js';
|
||||
|
||||
test('browser_console_messages', async ({ client }) => {
|
||||
test('browser_console_messages', async ({ client, server }) => {
|
||||
server.setContent('/', `
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<script>
|
||||
console.log("Hello, world!");
|
||||
console.error("Error");
|
||||
</script>
|
||||
</html>
|
||||
`, 'text/html');
|
||||
|
||||
await client.callTool({
|
||||
name: 'browser_navigate',
|
||||
arguments: {
|
||||
url: 'data:text/html,<html><script>console.log("Hello, world!");console.error("Error"); </script></html>',
|
||||
url: server.PREFIX,
|
||||
},
|
||||
});
|
||||
|
||||
const resource = await client.callTool({
|
||||
name: 'browser_console_messages',
|
||||
arguments: {},
|
||||
});
|
||||
expect(resource).toHaveTextContent([
|
||||
'[LOG] Hello, world!',
|
||||
|
||||
@@ -16,42 +16,43 @@
|
||||
|
||||
import { test, expect } from './fixtures.js';
|
||||
|
||||
test('browser_navigate', async ({ client }) => {
|
||||
test('browser_navigate', async ({ client, server }) => {
|
||||
expect(await client.callTool({
|
||||
name: 'browser_navigate',
|
||||
arguments: {
|
||||
url: 'data:text/html,<html><title>Title</title><body>Hello, world!</body></html>',
|
||||
},
|
||||
arguments: { url: server.HELLO_WORLD },
|
||||
})).toHaveTextContent(`
|
||||
- Ran Playwright code:
|
||||
\`\`\`js
|
||||
// Navigate to data:text/html,<html><title>Title</title><body>Hello, world!</body></html>
|
||||
await page.goto('data:text/html,<html><title>Title</title><body>Hello, world!</body></html>');
|
||||
// Navigate to ${server.HELLO_WORLD}
|
||||
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 Snapshot
|
||||
\`\`\`yaml
|
||||
- generic [ref=s1e2]: 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({
|
||||
name: 'browser_navigate',
|
||||
arguments: {
|
||||
url: 'data:text/html,<html><title>Title</title><button>Submit</button></html>',
|
||||
},
|
||||
arguments: { url: server.PREFIX },
|
||||
});
|
||||
|
||||
expect(await client.callTool({
|
||||
name: 'browser_click',
|
||||
arguments: {
|
||||
element: 'Submit button',
|
||||
ref: 's1e3',
|
||||
ref: 'e2',
|
||||
},
|
||||
})).toHaveTextContent(`
|
||||
- Ran Playwright code:
|
||||
@@ -60,28 +61,34 @@ test('browser_click', async ({ client }) => {
|
||||
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 Snapshot
|
||||
\`\`\`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({
|
||||
name: 'browser_navigate',
|
||||
arguments: {
|
||||
url: 'data:text/html,<html><title>Title</title><select><option value="foo">Foo</option><option value="bar">Bar</option></select></html>',
|
||||
},
|
||||
arguments: { url: server.PREFIX },
|
||||
});
|
||||
|
||||
expect(await client.callTool({
|
||||
name: 'browser_select_option',
|
||||
arguments: {
|
||||
element: 'Select',
|
||||
ref: 's1e3',
|
||||
ref: 'e2',
|
||||
values: ['bar'],
|
||||
},
|
||||
})).toHaveTextContent(`
|
||||
@@ -91,30 +98,37 @@ test('browser_select_option', async ({ client }) => {
|
||||
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 Snapshot
|
||||
\`\`\`yaml
|
||||
- combobox [ref=s2e3]:
|
||||
- combobox [ref=e2]:
|
||||
- option "Foo"
|
||||
- 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({
|
||||
name: 'browser_navigate',
|
||||
arguments: {
|
||||
url: 'data:text/html,<html><title>Title</title><select multiple><option value="foo">Foo</option><option value="bar">Bar</option><option value="baz">Baz</option></select></html>',
|
||||
},
|
||||
arguments: { url: server.PREFIX },
|
||||
});
|
||||
|
||||
expect(await client.callTool({
|
||||
name: 'browser_select_option',
|
||||
arguments: {
|
||||
element: 'Select',
|
||||
ref: 's1e3',
|
||||
ref: 'e2',
|
||||
values: ['bar', 'baz'],
|
||||
},
|
||||
})).toHaveTextContent(`
|
||||
@@ -124,52 +138,62 @@ test('browser_select_option (multiple)', async ({ client }) => {
|
||||
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 Snapshot
|
||||
\`\`\`yaml
|
||||
- listbox [ref=s2e3]:
|
||||
- option "Foo" [ref=s2e4]
|
||||
- option "Bar" [selected] [ref=s2e5]
|
||||
- option "Baz" [selected] [ref=s2e6]
|
||||
- listbox [ref=e2]:
|
||||
- option "Foo" [ref=e3]
|
||||
- option "Bar" [selected] [ref=e4]
|
||||
- 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({
|
||||
name: 'browser_navigate',
|
||||
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({
|
||||
name: 'browser_type',
|
||||
arguments: {
|
||||
element: 'textbox',
|
||||
ref: 's1e3',
|
||||
ref: 'e2',
|
||||
text: 'Hi!',
|
||||
submit: true,
|
||||
},
|
||||
});
|
||||
expect(await client.callTool({
|
||||
name: 'browser_console_messages',
|
||||
arguments: {},
|
||||
})).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({
|
||||
name: 'browser_navigate',
|
||||
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({
|
||||
name: 'browser_type',
|
||||
arguments: {
|
||||
element: 'textbox',
|
||||
ref: 's1e3',
|
||||
ref: 'e2',
|
||||
text: 'Hi!',
|
||||
submit: true,
|
||||
slowly: true,
|
||||
@@ -177,7 +201,6 @@ test('browser_type (slowly)', async ({ client }) => {
|
||||
});
|
||||
expect(await client.callTool({
|
||||
name: 'browser_console_messages',
|
||||
arguments: {},
|
||||
})).toHaveTextContent([
|
||||
'[LOG] Key pressed: H Text: ',
|
||||
'[LOG] Key pressed: i Text: H',
|
||||
@@ -186,12 +209,18 @@ test('browser_type (slowly)', async ({ client }) => {
|
||||
].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({
|
||||
name: 'browser_navigate',
|
||||
arguments: {
|
||||
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>',
|
||||
},
|
||||
arguments: { url: server.PREFIX },
|
||||
});
|
||||
|
||||
const response = await client.callTool({
|
||||
@@ -206,5 +235,5 @@ test('browser_resize', async ({ client }) => {
|
||||
// Resize browser window to 390x780
|
||||
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');
|
||||
});
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
import { test, expect } from './fixtures.js';
|
||||
|
||||
test('--device should work', async ({ startClient, server }) => {
|
||||
const client = await startClient({
|
||||
const { client } = await startClient({
|
||||
args: ['--device', 'iPhone 15'],
|
||||
});
|
||||
|
||||
|
||||
@@ -19,19 +19,18 @@ import { test, expect } from './fixtures.js';
|
||||
// https://github.com/microsoft/playwright/issues/35663
|
||||
test.skip(({ mcpBrowser, mcpHeadless }) => mcpBrowser === 'webkit' && mcpHeadless);
|
||||
|
||||
test('alert dialog', async ({ client }) => {
|
||||
test('alert dialog', async ({ client, server }) => {
|
||||
server.setContent('/', `<button onclick="alert('Alert')">Button</button>`, 'text/html');
|
||||
expect(await client.callTool({
|
||||
name: 'browser_navigate',
|
||||
arguments: {
|
||||
url: 'data:text/html,<html><title>Title</title><button onclick="alert(\'Alert\')">Button</button></html>',
|
||||
},
|
||||
})).toContainTextContent('- button "Button" [ref=s1e3]');
|
||||
arguments: { url: server.PREFIX },
|
||||
})).toContainTextContent('- button "Button" [ref=e2]');
|
||||
|
||||
expect(await client.callTool({
|
||||
name: 'browser_click',
|
||||
arguments: {
|
||||
element: 'Button',
|
||||
ref: 's1e3',
|
||||
ref: 'e2',
|
||||
},
|
||||
})).toHaveTextContent(`- Ran Playwright code:
|
||||
\`\`\`js
|
||||
@@ -55,29 +54,35 @@ await page.getByRole('button', { name: 'Button' }).click();
|
||||
// <internal code to handle "alert" dialog>
|
||||
\`\`\`
|
||||
|
||||
- Page URL: data:text/html,<html><title>Title</title><button onclick="alert('Alert')">Button</button></html>
|
||||
- Page Title: Title
|
||||
- Page URL: ${server.PREFIX}
|
||||
- Page Title:
|
||||
- Page Snapshot
|
||||
\`\`\`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');
|
||||
|
||||
server.setContent('/', `
|
||||
<title>Title</title>
|
||||
<body>
|
||||
<button onclick="alert('Alert 1');alert('Alert 2');">Button</button>
|
||||
</body>
|
||||
`, 'text/html');
|
||||
|
||||
expect(await client.callTool({
|
||||
name: 'browser_navigate',
|
||||
arguments: {
|
||||
url: 'data:text/html,<html><title>Title</title><button onclick="alert(\'Alert 1\');alert(\'Alert 2\');">Button</button></html>',
|
||||
},
|
||||
})).toContainTextContent('- button "Button" [ref=s1e3]');
|
||||
arguments: { url: server.PREFIX },
|
||||
})).toContainTextContent('- button "Button" [ref=e2]');
|
||||
|
||||
expect(await client.callTool({
|
||||
name: 'browser_click',
|
||||
arguments: {
|
||||
element: 'Button',
|
||||
ref: 's1e3',
|
||||
ref: 'e2',
|
||||
},
|
||||
})).toHaveTextContent(`- Ran Playwright code:
|
||||
\`\`\`js
|
||||
@@ -98,19 +103,24 @@ await page.getByRole('button', { name: 'Button' }).click();
|
||||
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({
|
||||
name: 'browser_navigate',
|
||||
arguments: {
|
||||
url: 'data:text/html,<html><title>Title</title><button onclick="document.body.textContent = confirm(\'Confirm\')">Button</button></html>',
|
||||
},
|
||||
})).toContainTextContent('- button "Button" [ref=s1e3]');
|
||||
arguments: { url: server.PREFIX },
|
||||
})).toContainTextContent('- button "Button" [ref=e2]');
|
||||
|
||||
expect(await client.callTool({
|
||||
name: 'browser_click',
|
||||
arguments: {
|
||||
element: 'Button',
|
||||
ref: 's1e3',
|
||||
ref: 'e2',
|
||||
},
|
||||
})).toContainTextContent(`### Modal state
|
||||
- ["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(`- Page Snapshot
|
||||
\`\`\`yaml
|
||||
- generic [ref=s2e2]: "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({
|
||||
name: 'browser_navigate',
|
||||
arguments: {
|
||||
url: 'data:text/html,<html><title>Title</title><button onclick="document.body.textContent = confirm(\'Confirm\')">Button</button></html>',
|
||||
},
|
||||
})).toContainTextContent('- button "Button" [ref=s1e3]');
|
||||
arguments: { url: server.PREFIX },
|
||||
})).toContainTextContent('- button "Button" [ref=e2]');
|
||||
|
||||
expect(await client.callTool({
|
||||
name: 'browser_click',
|
||||
arguments: {
|
||||
element: 'Button',
|
||||
ref: 's1e3',
|
||||
ref: 'e2',
|
||||
},
|
||||
})).toContainTextContent(`### Modal state
|
||||
- ["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
|
||||
\`\`\`yaml
|
||||
- generic [ref=s2e2]: "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({
|
||||
name: 'browser_navigate',
|
||||
arguments: {
|
||||
url: 'data:text/html,<html><title>Title</title><button onclick="document.body.textContent = prompt(\'Prompt\')">Button</button></html>',
|
||||
},
|
||||
})).toContainTextContent('- button "Button" [ref=s1e3]');
|
||||
arguments: { url: server.PREFIX },
|
||||
})).toContainTextContent('- button "Button" [ref=e2]');
|
||||
|
||||
expect(await client.callTool({
|
||||
name: 'browser_click',
|
||||
arguments: {
|
||||
element: 'Button',
|
||||
ref: 's1e3',
|
||||
ref: 'e2',
|
||||
},
|
||||
})).toContainTextContent(`### Modal state
|
||||
- ["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
|
||||
\`\`\`yaml
|
||||
- generic [ref=s2e2]: Answer
|
||||
- generic [ref=e1]: Answer
|
||||
\`\`\``);
|
||||
});
|
||||
|
||||
@@ -16,18 +16,21 @@
|
||||
|
||||
import { test, expect } from './fixtures.js';
|
||||
import fs from 'fs/promises';
|
||||
import path from 'path';
|
||||
|
||||
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({
|
||||
name: 'browser_navigate',
|
||||
arguments: {
|
||||
url: 'data:text/html,<html><title>Title</title><input type="file" /><button>Button</button></html>',
|
||||
},
|
||||
arguments: { url: server.PREFIX },
|
||||
})).toContainTextContent(`
|
||||
\`\`\`yaml
|
||||
- button "Choose File" [ref=s1e3]
|
||||
- button "Button" [ref=s1e4]
|
||||
- generic [ref=e1]:
|
||||
- button "Choose File" [ref=e2]
|
||||
- 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',
|
||||
arguments: {
|
||||
element: 'Textbox',
|
||||
ref: 's1e3',
|
||||
ref: 'e2',
|
||||
},
|
||||
})).toContainTextContent(`### Modal state
|
||||
- [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!');
|
||||
|
||||
{
|
||||
@@ -64,8 +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).toContainTextContent(`
|
||||
\`\`\`yaml
|
||||
- button "Choose File" [ref=s3e3]
|
||||
- button "Button" [ref=s3e4]
|
||||
- generic [ref=e1]:
|
||||
- button "Choose File" [ref=e2]
|
||||
- button "Button" [ref=e3]
|
||||
\`\`\``);
|
||||
}
|
||||
|
||||
@@ -74,7 +78,7 @@ The tool "browser_file_upload" can only be used when there is related modal stat
|
||||
name: 'browser_click',
|
||||
arguments: {
|
||||
element: 'Textbox',
|
||||
ref: 's3e3',
|
||||
ref: 'e2',
|
||||
},
|
||||
});
|
||||
|
||||
@@ -86,7 +90,7 @@ The tool "browser_file_upload" can only be used when there is related modal stat
|
||||
name: 'browser_click',
|
||||
arguments: {
|
||||
element: 'Button',
|
||||
ref: 's4e4',
|
||||
ref: 'e3',
|
||||
},
|
||||
});
|
||||
|
||||
@@ -96,26 +100,48 @@ The tool "browser_file_upload" can only be used when there is related modal stat
|
||||
}
|
||||
});
|
||||
|
||||
test('clicking on download link emits download', async ({ startClient }, testInfo) => {
|
||||
const outputDir = testInfo.outputPath('output');
|
||||
const client = await startClient({
|
||||
config: { outputDir },
|
||||
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: 'data:text/html,<a href="data:text/plain,Hello world!" download="test.txt">Download</a>',
|
||||
},
|
||||
})).toContainTextContent('- link "Download" [ref=s1e3]');
|
||||
await client.callTool({
|
||||
name: 'browser_click',
|
||||
arguments: {
|
||||
element: 'Download link',
|
||||
ref: 's1e3',
|
||||
url: server.PREFIX + 'download',
|
||||
},
|
||||
});
|
||||
await expect.poll(() => client.callTool({ name: 'browser_snapshot', arguments: {} })).toContainTextContent(`
|
||||
### Downloads
|
||||
- Downloaded file test.txt to ${path.join(outputDir, 'test.txt')}`);
|
||||
})).toContainTextContent('### Downloads');
|
||||
});
|
||||
|
||||
@@ -22,21 +22,27 @@ import { chromium } from 'playwright';
|
||||
import { test as baseTest, expect as baseExpect } from '@playwright/test';
|
||||
import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js';
|
||||
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
|
||||
import { ChildProcessWithoutNullStreams, spawn } from 'child_process';
|
||||
import { TestServer } from './testserver/index.ts';
|
||||
|
||||
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 = {
|
||||
client: Client;
|
||||
visionClient: Client;
|
||||
startClient: (options?: { args?: string[], config?: Config }) => Promise<Client>;
|
||||
startClient: (options?: { clientName?: string, args?: string[], config?: Config }) => Promise<{ client: Client, stderr: () => string }>;
|
||||
wsEndpoint: string;
|
||||
cdpEndpoint: (port?: number) => Promise<string>;
|
||||
cdpServer: CDPServer;
|
||||
server: TestServer;
|
||||
httpsServer: TestServer;
|
||||
mcpHeadless: boolean;
|
||||
@@ -49,19 +55,24 @@ type WorkerFixtures = {
|
||||
export const test = baseTest.extend<TestFixtures & TestOptions, WorkerFixtures>({
|
||||
|
||||
client: async ({ startClient }, use) => {
|
||||
await use(await startClient());
|
||||
const { client } = await startClient();
|
||||
await use(client);
|
||||
},
|
||||
|
||||
visionClient: async ({ startClient }, use) => {
|
||||
await use(await startClient({ args: ['--vision'] }));
|
||||
const { client } = await startClient({ args: ['--vision'] });
|
||||
await use(client);
|
||||
},
|
||||
|
||||
startClient: async ({ mcpHeadless, mcpBrowser }, use, testInfo) => {
|
||||
startClient: async ({ mcpHeadless, mcpBrowser, mcpMode }, use, testInfo) => {
|
||||
const userDataDir = testInfo.outputPath('user-data-dir');
|
||||
const configDir = path.dirname(test.info().config.configFile!);
|
||||
let client: Client | undefined;
|
||||
|
||||
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)
|
||||
args.push('--headless');
|
||||
if (mcpBrowser)
|
||||
@@ -71,18 +82,18 @@ export const test = baseTest.extend<TestFixtures & TestOptions, WorkerFixtures>(
|
||||
if (options?.config) {
|
||||
const configFile = testInfo.outputPath('config.json');
|
||||
await fs.promises.writeFile(configFile, JSON.stringify(options.config, null, 2));
|
||||
args.push(`--config=${configFile}`);
|
||||
args.push(`--config=${path.relative(configDir, configFile)}`);
|
||||
}
|
||||
// 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 transport = new StdioClientTransport({
|
||||
command: 'node',
|
||||
args: [path.join(path.dirname(__filename), '../cli.js'), ...args],
|
||||
|
||||
client = new Client({ name: options?.clientName ?? 'test', version: '1.0.0' });
|
||||
const transport = createTransport(args, mcpMode);
|
||||
let stderr = '';
|
||||
transport.stderr?.on('data', data => {
|
||||
stderr += data.toString();
|
||||
});
|
||||
client = new Client({ name: 'test', version: '1.0.0' });
|
||||
await client.connect(transport);
|
||||
await client.ping();
|
||||
return client;
|
||||
return { client, stderr: () => stderr };
|
||||
});
|
||||
|
||||
await client?.close();
|
||||
@@ -94,34 +105,25 @@ export const test = baseTest.extend<TestFixtures & TestOptions, WorkerFixtures>(
|
||||
await browserServer.close();
|
||||
},
|
||||
|
||||
cdpEndpoint: async ({ }, use, testInfo) => {
|
||||
let browserProcess: ChildProcessWithoutNullStreams | undefined;
|
||||
cdpServer: async ({ mcpBrowser }, use, testInfo) => {
|
||||
test.skip(!['chrome', 'msedge', 'chromium'].includes(mcpBrowser!), 'CDP is not supported for non-Chromium browsers');
|
||||
|
||||
await use(async port => {
|
||||
if (!port)
|
||||
port = 3200 + test.info().parallelIndex;
|
||||
if (browserProcess)
|
||||
return `http://localhost:${port}`;
|
||||
browserProcess = spawn(chromium.executablePath(), [
|
||||
`--user-data-dir=${testInfo.outputPath('user-data-dir')}`,
|
||||
let browserContext: BrowserContext | undefined;
|
||||
const port = 3200 + test.info().parallelIndex;
|
||||
await use({
|
||||
endpoint: `http://localhost:${port}`,
|
||||
start: async () => {
|
||||
browserContext = await chromium.launchPersistentContext(testInfo.outputPath('cdp-user-data-dir'), {
|
||||
channel: mcpBrowser,
|
||||
headless: true,
|
||||
args: [
|
||||
`--remote-debugging-port=${port}`,
|
||||
`--no-first-run`,
|
||||
`--no-sandbox`,
|
||||
`--headless`,
|
||||
'--use-mock-keychain',
|
||||
`data:text/html,hello world`,
|
||||
], {
|
||||
stdio: 'pipe',
|
||||
],
|
||||
});
|
||||
await new Promise<void>(resolve => {
|
||||
browserProcess!.stderr.on('data', data => {
|
||||
if (data.toString().includes('DevTools listening on '))
|
||||
resolve();
|
||||
return browserContext;
|
||||
}
|
||||
});
|
||||
});
|
||||
return `http://localhost:${port}`;
|
||||
});
|
||||
browserProcess?.kill();
|
||||
await browserContext?.close();
|
||||
},
|
||||
|
||||
mcpHeadless: async ({ headless }, use) => {
|
||||
@@ -130,6 +132,8 @@ export const test = baseTest.extend<TestFixtures & TestOptions, WorkerFixtures>(
|
||||
|
||||
mcpBrowser: ['chrome', { option: true }],
|
||||
|
||||
mcpMode: [undefined, { option: true }],
|
||||
|
||||
_workerServers: [async ({}, use, workerInfo) => {
|
||||
const port = 8907 + workerInfo.workerIndex * 4;
|
||||
const server = await TestServer.create(port);
|
||||
@@ -156,6 +160,30 @@ export const test = baseTest.extend<TestFixtures & TestOptions, 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), '..'),
|
||||
stderr: 'pipe',
|
||||
env: {
|
||||
...process.env,
|
||||
DEBUG: 'pw:mcp:test',
|
||||
DEBUG_COLORS: '0',
|
||||
DEBUG_HIDE_DATE: '1',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
type Response = Awaited<ReturnType<Client['callTool']>>;
|
||||
|
||||
export const expect = baseExpect.extend({
|
||||
@@ -209,3 +237,7 @@ export const expect = baseExpect.extend({
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
export function formatOutput(output: string): string[] {
|
||||
return output.split('\n').map(line => line.replace(/^pw:mcp:test /, '').replace(/test-results.*/, '').trim()).filter(Boolean);
|
||||
}
|
||||
|
||||
@@ -20,6 +20,7 @@ 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) => {
|
||||
|
||||
@@ -24,19 +24,21 @@ test('stitched aria frames', async ({ client }) => {
|
||||
},
|
||||
})).toContainTextContent(`
|
||||
\`\`\`yaml
|
||||
- heading "Hello" [level=1] [ref=s1e3]
|
||||
- iframe [ref=s1e4]:
|
||||
- button "World" [ref=f1s1e3]
|
||||
- main [ref=f1s1e4]:
|
||||
- iframe [ref=f1s1e5]:
|
||||
- paragraph [ref=f2s1e3]: Nested
|
||||
- generic [ref=e1]:
|
||||
- heading "Hello" [level=1] [ref=e2]
|
||||
- iframe [ref=e3]:
|
||||
- generic [ref=f1e1]:
|
||||
- button "World" [ref=f1e2]
|
||||
- main [ref=f1e3]:
|
||||
- iframe [ref=f1e4]:
|
||||
- paragraph [ref=f2e2]: Nested
|
||||
\`\`\``);
|
||||
|
||||
expect(await client.callTool({
|
||||
name: 'browser_click',
|
||||
arguments: {
|
||||
element: 'World',
|
||||
ref: 'f1s1e3',
|
||||
ref: 'f1e2',
|
||||
},
|
||||
})).toContainTextContent(`// Click World`);
|
||||
});
|
||||
|
||||
@@ -20,6 +20,5 @@ test('browser_install', async ({ client, mcpBrowser }) => {
|
||||
test.skip(mcpBrowser !== 'chromium', 'Test only chromium');
|
||||
expect(await client.callTool({
|
||||
name: 'browser_install',
|
||||
arguments: {},
|
||||
})).toContainTextContent(`No open pages available.`);
|
||||
});
|
||||
|
||||
@@ -14,36 +14,144 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { test, expect } from './fixtures.js';
|
||||
import fs from 'fs';
|
||||
|
||||
test('test reopen browser', async ({ client }) => {
|
||||
import { test, expect, formatOutput } from './fixtures.js';
|
||||
|
||||
test('test reopen browser', async ({ startClient, server }) => {
|
||||
const { client, stderr } = await startClient();
|
||||
await client.callTool({
|
||||
name: 'browser_navigate',
|
||||
arguments: {
|
||||
url: 'data:text/html,<html><title>Title</title><body>Hello, world!</body></html>',
|
||||
},
|
||||
arguments: { url: server.HELLO_WORLD },
|
||||
});
|
||||
|
||||
expect(await client.callTool({
|
||||
name: 'browser_close',
|
||||
arguments: {},
|
||||
})).toContainTextContent('No open pages available');
|
||||
|
||||
expect(await client.callTool({
|
||||
name: 'browser_navigate',
|
||||
arguments: {
|
||||
url: 'data:text/html,<html><title>Title</title><body>Hello, world!</body></html>',
|
||||
},
|
||||
})).toContainTextContent(`- generic [ref=s1e2]: Hello, world!`);
|
||||
arguments: { url: server.HELLO_WORLD },
|
||||
})).toContainTextContent(`- generic [ref=e1]: Hello, world!`);
|
||||
|
||||
await client.close();
|
||||
|
||||
if (process.platform === 'win32')
|
||||
return;
|
||||
|
||||
await expect.poll(() => formatOutput(stderr()), { timeout: 0 }).toEqual([
|
||||
'create context',
|
||||
'create browser context (persistent)',
|
||||
'lock user data dir',
|
||||
'close context',
|
||||
'close browser context (persistent)',
|
||||
'release user data dir',
|
||||
'close browser context complete (persistent)',
|
||||
'create browser context (persistent)',
|
||||
'lock user data dir',
|
||||
'close context',
|
||||
'close browser context (persistent)',
|
||||
'release user data dir',
|
||||
'close browser context complete (persistent)',
|
||||
]);
|
||||
});
|
||||
|
||||
test('executable path', async ({ startClient }) => {
|
||||
const client = await startClient({ args: [`--executable-path=bogus`] });
|
||||
test('executable path', async ({ startClient, server }) => {
|
||||
const { client } = await startClient({ args: [`--executable-path=bogus`] });
|
||||
const response = await client.callTool({
|
||||
name: 'browser_navigate',
|
||||
arguments: {
|
||||
url: 'data:text/html,<html><title>Title</title><body>Hello, world!</body></html>',
|
||||
},
|
||||
arguments: { url: server.HELLO_WORLD },
|
||||
});
|
||||
expect(response).toContainTextContent(`executable doesn't exist`);
|
||||
});
|
||||
|
||||
test('persistent context', async ({ startClient, server }) => {
|
||||
server.setContent('/', `
|
||||
<body>
|
||||
</body>
|
||||
<script>
|
||||
document.body.textContent = localStorage.getItem('test') ? 'Storage: YES' : 'Storage: NO';
|
||||
localStorage.setItem('test', 'test');
|
||||
</script>
|
||||
`, 'text/html');
|
||||
|
||||
const { client } = await startClient();
|
||||
const response = await client.callTool({
|
||||
name: 'browser_navigate',
|
||||
arguments: { url: server.PREFIX },
|
||||
});
|
||||
expect(response).toContainTextContent(`Storage: NO`);
|
||||
|
||||
await new Promise(resolve => setTimeout(resolve, 3000));
|
||||
|
||||
await client.callTool({
|
||||
name: 'browser_close',
|
||||
});
|
||||
|
||||
const { client: client2 } = await startClient();
|
||||
const response2 = await client2.callTool({
|
||||
name: 'browser_navigate',
|
||||
arguments: { url: server.PREFIX },
|
||||
});
|
||||
|
||||
expect(response2).toContainTextContent(`Storage: YES`);
|
||||
});
|
||||
|
||||
test('isolated context', async ({ startClient, server }) => {
|
||||
server.setContent('/', `
|
||||
<body>
|
||||
</body>
|
||||
<script>
|
||||
document.body.textContent = localStorage.getItem('test') ? 'Storage: YES' : 'Storage: NO';
|
||||
localStorage.setItem('test', 'test');
|
||||
</script>
|
||||
`, 'text/html');
|
||||
|
||||
const { client: client1 } = await startClient({ args: [`--isolated`] });
|
||||
const response = await client1.callTool({
|
||||
name: 'browser_navigate',
|
||||
arguments: { url: server.PREFIX },
|
||||
});
|
||||
expect(response).toContainTextContent(`Storage: NO`);
|
||||
|
||||
await client1.callTool({
|
||||
name: 'browser_close',
|
||||
});
|
||||
|
||||
const { client: client2 } = await startClient({ args: [`--isolated`] });
|
||||
const response2 = await client2.callTool({
|
||||
name: 'browser_navigate',
|
||||
arguments: { url: server.PREFIX },
|
||||
});
|
||||
expect(response2).toContainTextContent(`Storage: NO`);
|
||||
});
|
||||
|
||||
test('isolated context with storage state', async ({ startClient, server }, testInfo) => {
|
||||
const storageStatePath = testInfo.outputPath('storage-state.json');
|
||||
await fs.promises.writeFile(storageStatePath, JSON.stringify({
|
||||
origins: [
|
||||
{
|
||||
origin: server.PREFIX,
|
||||
localStorage: [{ name: 'test', value: 'session-value' }],
|
||||
},
|
||||
],
|
||||
}));
|
||||
|
||||
server.setContent('/', `
|
||||
<body>
|
||||
</body>
|
||||
<script>
|
||||
document.body.textContent = 'Storage: ' + localStorage.getItem('test');
|
||||
</script>
|
||||
`, 'text/html');
|
||||
|
||||
const { client } = await startClient({ args: [
|
||||
`--isolated`,
|
||||
`--storage-state=${storageStatePath}`,
|
||||
] });
|
||||
const response = await client.callTool({
|
||||
name: 'browser_navigate',
|
||||
arguments: { url: server.PREFIX },
|
||||
});
|
||||
expect(response).toContainTextContent(`Storage: session-value`);
|
||||
});
|
||||
|
||||
28
tests/library.spec.ts
Normal file
28
tests/library.spec.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
/**
|
||||
* Copyright (c) Microsoft Corporation.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
import { test, expect } from './fixtures.js';
|
||||
import fs from 'node:fs/promises';
|
||||
import child_process from 'node:child_process';
|
||||
|
||||
test('library can be used from CommonJS', { annotation: { type: 'issue', description: 'https://github.com/microsoft/playwright-mcp/issues/456' } }, async ({}, testInfo) => {
|
||||
const file = testInfo.outputPath('main.cjs');
|
||||
await fs.writeFile(file, `
|
||||
import('@playwright/mcp')
|
||||
.then(playwrightMCP => playwrightMCP.createConnection())
|
||||
.then(() => console.log('OK'));
|
||||
`);
|
||||
expect(child_process.execSync(`node ${file}`, { encoding: 'utf-8' })).toContain('OK');
|
||||
});
|
||||
@@ -17,15 +17,11 @@
|
||||
import { test, expect } from './fixtures.js';
|
||||
|
||||
test('browser_network_requests', async ({ client, server }) => {
|
||||
server.route('/', (req, res) => {
|
||||
res.writeHead(200, { 'Content-Type': 'text/html' });
|
||||
res.end(`<button onclick="fetch('/json')">Click me</button>`);
|
||||
});
|
||||
server.setContent('/', `
|
||||
<button onclick="fetch('/json')">Click me</button>
|
||||
`, 'text/html');
|
||||
|
||||
server.route('/json', (req, res) => {
|
||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ name: 'John Doe' }));
|
||||
});
|
||||
server.setContent('/json', JSON.stringify({ name: 'John Doe' }), 'application/json');
|
||||
|
||||
await client.callTool({
|
||||
name: 'browser_navigate',
|
||||
@@ -38,12 +34,12 @@ test('browser_network_requests', async ({ client, server }) => {
|
||||
name: 'browser_click',
|
||||
arguments: {
|
||||
element: 'Click me button',
|
||||
ref: 's1e3',
|
||||
ref: 'e2',
|
||||
},
|
||||
});
|
||||
|
||||
await expect.poll(() => client.callTool({
|
||||
name: 'browser_network_requests',
|
||||
arguments: {},
|
||||
})).toHaveTextContent(`[GET] http://localhost:${server.PORT}/json => [200] OK`);
|
||||
})).toHaveTextContent(`[GET] ${`${server.PREFIX}`} => [200] OK
|
||||
[GET] ${`${server.PREFIX}json`} => [200] OK`);
|
||||
});
|
||||
|
||||
@@ -14,15 +14,15 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import fs from 'fs';
|
||||
|
||||
import { test, expect } from './fixtures.js';
|
||||
|
||||
test('save as pdf unavailable', async ({ startClient }) => {
|
||||
const client = await startClient({ args: ['--caps="no-pdf"'] });
|
||||
test('save as pdf unavailable', async ({ startClient, server }) => {
|
||||
const { client } = await startClient({ args: ['--caps="no-pdf"'] });
|
||||
await client.callTool({
|
||||
name: 'browser_navigate',
|
||||
arguments: {
|
||||
url: 'data:text/html,<html><title>Title</title><body>Hello, world!</body></html>',
|
||||
},
|
||||
arguments: { url: server.HELLO_WORLD },
|
||||
});
|
||||
|
||||
expect(await client.callTool({
|
||||
@@ -30,18 +30,54 @@ test('save as pdf unavailable', async ({ startClient }) => {
|
||||
})).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.');
|
||||
|
||||
expect(await client.callTool({
|
||||
name: 'browser_navigate',
|
||||
arguments: {
|
||||
url: 'data:text/html,<html><title>Title</title><body>Hello, world!</body></html>',
|
||||
},
|
||||
})).toContainTextContent(`- generic [ref=s1e2]: Hello, world!`);
|
||||
arguments: { url: server.HELLO_WORLD },
|
||||
})).toContainTextContent(`- generic [ref=e1]: Hello, world!`);
|
||||
|
||||
const response = await client.callTool({
|
||||
name: 'browser_pdf_save',
|
||||
arguments: {},
|
||||
});
|
||||
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');
|
||||
});
|
||||
@@ -18,17 +18,17 @@ import fs from 'fs';
|
||||
|
||||
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({
|
||||
name: 'browser_navigate',
|
||||
arguments: {
|
||||
url: 'data:text/html,<html><title>Title</title><body>Hello, world!</body></html>',
|
||||
},
|
||||
})).toContainTextContent(`Navigate to data:text/html`);
|
||||
arguments: { url: server.HELLO_WORLD },
|
||||
})).toContainTextContent(`Navigate to http://localhost`);
|
||||
|
||||
expect(await client.callTool({
|
||||
name: 'browser_take_screenshot',
|
||||
arguments: {},
|
||||
})).toEqual({
|
||||
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({
|
||||
name: 'browser_navigate',
|
||||
arguments: {
|
||||
url: 'data:text/html,<html><title>Title</title><button>Hello, world!</button></html>',
|
||||
},
|
||||
})).toContainTextContent(`[ref=s1e3]`);
|
||||
arguments: { url: server.HELLO_WORLD },
|
||||
})).toContainTextContent(`[ref=e1]`);
|
||||
|
||||
expect(await client.callTool({
|
||||
name: 'browser_take_screenshot',
|
||||
arguments: {
|
||||
element: 'hello button',
|
||||
ref: 's1e3',
|
||||
ref: 'e1',
|
||||
},
|
||||
})).toEqual({
|
||||
content: [
|
||||
@@ -66,60 +67,160 @@ test('browser_take_screenshot (element)', async ({ client }) => {
|
||||
type: 'image',
|
||||
},
|
||||
{
|
||||
text: expect.stringContaining(`page.getByRole('button', { name: 'Hello, world!' }).screenshot`),
|
||||
text: expect.stringContaining(`page.getByText('Hello, world!').screenshot`),
|
||||
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 client = await startClient({
|
||||
const { client } = await startClient({
|
||||
config: { outputDir },
|
||||
});
|
||||
expect(await client.callTool({
|
||||
name: 'browser_navigate',
|
||||
arguments: {
|
||||
url: 'data:text/html,<html><title>Title</title><body>Hello, world!</body></html>',
|
||||
},
|
||||
})).toContainTextContent(`Navigate to data:text/html`);
|
||||
arguments: { url: server.HELLO_WORLD },
|
||||
})).toContainTextContent(`Navigate to http://localhost`);
|
||||
|
||||
await client.callTool({
|
||||
name: 'browser_take_screenshot',
|
||||
arguments: {},
|
||||
});
|
||||
|
||||
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 }) => {
|
||||
const client = await 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({
|
||||
config: {
|
||||
tools: {
|
||||
browser_take_screenshot: {
|
||||
omitBase64: true,
|
||||
},
|
||||
},
|
||||
outputDir,
|
||||
imageResponses: 'omit',
|
||||
},
|
||||
});
|
||||
|
||||
expect(await client.callTool({
|
||||
name: 'browser_navigate',
|
||||
arguments: {
|
||||
url: 'data:text/html,<html><title>Title</title><body>Hello, world!</body></html>',
|
||||
},
|
||||
})).toContainTextContent(`Navigate to data:text/html`);
|
||||
arguments: { url: server.HELLO_WORLD },
|
||||
})).toContainTextContent(`Navigate to http://localhost`);
|
||||
|
||||
await client.callTool({
|
||||
name: 'browser_take_screenshot',
|
||||
});
|
||||
|
||||
expect(await client.callTool({
|
||||
name: 'browser_take_screenshot',
|
||||
})).toEqual({
|
||||
content: [
|
||||
{
|
||||
text: expect.stringContaining(`Screenshot viewport and save it as`),
|
||||
type: 'text',
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
test('browser_take_screenshot (cursor)', async ({ startClient, server }, testInfo) => {
|
||||
const outputDir = testInfo.outputPath('output');
|
||||
|
||||
const { client } = await startClient({
|
||||
clientName: 'cursor:vscode',
|
||||
config: { outputDir },
|
||||
});
|
||||
|
||||
expect(await client.callTool({
|
||||
name: 'browser_navigate',
|
||||
arguments: { url: server.HELLO_WORLD },
|
||||
})).toContainTextContent(`Navigate to http://localhost`);
|
||||
|
||||
await client.callTool({
|
||||
name: 'browser_take_screenshot',
|
||||
arguments: {},
|
||||
});
|
||||
|
||||
expect(await client.callTool({
|
||||
name: 'browser_take_screenshot',
|
||||
arguments: {},
|
||||
})).toEqual({
|
||||
content: [
|
||||
{
|
||||
|
||||
@@ -14,49 +14,231 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import fs from 'node:fs';
|
||||
import url from 'node:url';
|
||||
import { spawn } from 'node:child_process';
|
||||
|
||||
import { ChildProcess, spawn } from 'node:child_process';
|
||||
import path from 'node:path';
|
||||
import { test as baseTest } from './fixtures.js';
|
||||
import { expect } from 'playwright/test';
|
||||
import { SSEClientTransport } from '@modelcontextprotocol/sdk/client/sse.js';
|
||||
import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js';
|
||||
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
|
||||
|
||||
import { test as baseTest, expect } from './fixtures.js';
|
||||
import type { Config } from '../config.d.ts';
|
||||
|
||||
// NOTE: Can be removed when we drop Node.js 18 support and changed to import.meta.filename.
|
||||
const __filename = url.fileURLToPath(import.meta.url);
|
||||
|
||||
const test = baseTest.extend<{ serverEndpoint: string }>({
|
||||
serverEndpoint: async ({}, use) => {
|
||||
const cp = spawn('node', [path.join(path.dirname(__filename), '../cli.js'), '--port', '0'], { stdio: 'pipe' });
|
||||
try {
|
||||
let stdout = '';
|
||||
const url = await new Promise<string>(resolve => cp.stdout?.on('data', data => {
|
||||
stdout += data.toString();
|
||||
const match = stdout.match(/Listening on (http:\/\/.*)/);
|
||||
const test = baseTest.extend<{ serverEndpoint: (options?: { args?: string[], noPort?: boolean }) => Promise<{ url: URL, stderr: () => string }> }>({
|
||||
serverEndpoint: async ({ mcpHeadless }, use, testInfo) => {
|
||||
let cp: ChildProcess | undefined;
|
||||
const userDataDir = testInfo.outputPath('user-data-dir');
|
||||
await use(async (options?: { args?: string[], noPort?: boolean }) => {
|
||||
if (cp)
|
||||
throw new Error('Process already running');
|
||||
|
||||
cp = spawn('node', [
|
||||
path.join(path.dirname(__filename), '../cli.js'),
|
||||
...(options?.noPort ? [] : ['--port=0']),
|
||||
'--user-data-dir=' + userDataDir,
|
||||
...(mcpHeadless ? ['--headless'] : []),
|
||||
...(options?.args || []),
|
||||
], {
|
||||
stdio: 'pipe',
|
||||
env: {
|
||||
...process.env,
|
||||
DEBUG: 'pw:mcp:test',
|
||||
DEBUG_COLORS: '0',
|
||||
DEBUG_HIDE_DATE: '1',
|
||||
},
|
||||
});
|
||||
let stderr = '';
|
||||
const url = await new Promise<string>(resolve => cp!.stderr?.on('data', data => {
|
||||
stderr += data.toString();
|
||||
const match = stderr.match(/Listening on (http:\/\/.*)/);
|
||||
if (match)
|
||||
resolve(match[1]);
|
||||
}));
|
||||
|
||||
await use(url);
|
||||
} finally {
|
||||
cp.kill();
|
||||
}
|
||||
return { url: new URL(url), stderr: () => stderr };
|
||||
});
|
||||
cp?.kill('SIGTERM');
|
||||
},
|
||||
});
|
||||
|
||||
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 { url } = await serverEndpoint();
|
||||
const transport = new SSEClientTransport(url);
|
||||
const client = new Client({ name: 'test', version: '1.0.0' });
|
||||
await client.connect(transport);
|
||||
await client.ping();
|
||||
});
|
||||
|
||||
test('sse transport (config)', async ({ serverEndpoint }) => {
|
||||
const config: Config = {
|
||||
server: {
|
||||
port: 0,
|
||||
}
|
||||
};
|
||||
const configFile = test.info().outputPath('config.json');
|
||||
await fs.promises.writeFile(configFile, JSON.stringify(config, null, 2));
|
||||
|
||||
const { url } = await serverEndpoint({ noPort: true, args: ['--config=' + configFile] });
|
||||
const transport = new SSEClientTransport(url);
|
||||
const client = new Client({ name: 'test', version: '1.0.0' });
|
||||
await client.connect(transport);
|
||||
await client.ping();
|
||||
});
|
||||
|
||||
test('sse transport browser lifecycle (isolated)', async ({ serverEndpoint, server }) => {
|
||||
const { url, stderr } = await serverEndpoint({ args: ['--isolated'] });
|
||||
|
||||
const transport1 = new SSEClientTransport(url);
|
||||
const client1 = new Client({ name: 'test', version: '1.0.0' });
|
||||
await client1.connect(transport1);
|
||||
await client1.callTool({
|
||||
name: 'browser_navigate',
|
||||
arguments: { url: server.HELLO_WORLD },
|
||||
});
|
||||
await client1.close();
|
||||
|
||||
const transport2 = new SSEClientTransport(url);
|
||||
const client2 = new Client({ name: 'test', version: '1.0.0' });
|
||||
await client2.connect(transport2);
|
||||
await client2.callTool({
|
||||
name: 'browser_navigate',
|
||||
arguments: { url: server.HELLO_WORLD },
|
||||
});
|
||||
await client2.close();
|
||||
|
||||
await expect(async () => {
|
||||
const lines = stderr().split('\n');
|
||||
expect(lines.filter(line => line.match(/create SSE session/)).length).toBe(2);
|
||||
expect(lines.filter(line => line.match(/delete SSE session/)).length).toBe(2);
|
||||
|
||||
expect(lines.filter(line => line.match(/create context/)).length).toBe(2);
|
||||
expect(lines.filter(line => line.match(/close context/)).length).toBe(2);
|
||||
|
||||
expect(lines.filter(line => line.match(/create browser context \(isolated\)/)).length).toBe(2);
|
||||
expect(lines.filter(line => line.match(/close browser context \(isolated\)/)).length).toBe(2);
|
||||
|
||||
expect(lines.filter(line => line.match(/obtain browser \(isolated\)/)).length).toBe(2);
|
||||
expect(lines.filter(line => line.match(/close browser \(isolated\)/)).length).toBe(2);
|
||||
}).toPass();
|
||||
});
|
||||
|
||||
test('sse transport browser lifecycle (isolated, multiclient)', async ({ serverEndpoint, server }) => {
|
||||
const { url, stderr } = await serverEndpoint({ args: ['--isolated'] });
|
||||
|
||||
const transport1 = new SSEClientTransport(url);
|
||||
const client1 = new Client({ name: 'test', version: '1.0.0' });
|
||||
await client1.connect(transport1);
|
||||
await client1.callTool({
|
||||
name: 'browser_navigate',
|
||||
arguments: { url: server.HELLO_WORLD },
|
||||
});
|
||||
|
||||
const transport2 = new SSEClientTransport(url);
|
||||
const client2 = new Client({ name: 'test', version: '1.0.0' });
|
||||
await client2.connect(transport2);
|
||||
await client2.callTool({
|
||||
name: 'browser_navigate',
|
||||
arguments: { url: server.HELLO_WORLD },
|
||||
});
|
||||
await client1.close();
|
||||
|
||||
const transport3 = new SSEClientTransport(url);
|
||||
const client3 = new Client({ name: 'test', version: '1.0.0' });
|
||||
await client3.connect(transport3);
|
||||
await client3.callTool({
|
||||
name: 'browser_navigate',
|
||||
arguments: { url: server.HELLO_WORLD },
|
||||
});
|
||||
|
||||
await client2.close();
|
||||
await client3.close();
|
||||
|
||||
await expect(async () => {
|
||||
const lines = stderr().split('\n');
|
||||
expect(lines.filter(line => line.match(/create SSE session/)).length).toBe(3);
|
||||
expect(lines.filter(line => line.match(/delete SSE session/)).length).toBe(3);
|
||||
|
||||
expect(lines.filter(line => line.match(/create context/)).length).toBe(3);
|
||||
expect(lines.filter(line => line.match(/close context/)).length).toBe(3);
|
||||
|
||||
expect(lines.filter(line => line.match(/create browser context \(isolated\)/)).length).toBe(3);
|
||||
expect(lines.filter(line => line.match(/close browser context \(isolated\)/)).length).toBe(3);
|
||||
|
||||
expect(lines.filter(line => line.match(/obtain browser \(isolated\)/)).length).toBe(1);
|
||||
expect(lines.filter(line => line.match(/close browser \(isolated\)/)).length).toBe(1);
|
||||
}).toPass();
|
||||
});
|
||||
|
||||
test('sse transport browser lifecycle (persistent)', async ({ serverEndpoint, server }) => {
|
||||
const { url, stderr } = await serverEndpoint();
|
||||
|
||||
const transport1 = new SSEClientTransport(url);
|
||||
const client1 = new Client({ name: 'test', version: '1.0.0' });
|
||||
await client1.connect(transport1);
|
||||
await client1.callTool({
|
||||
name: 'browser_navigate',
|
||||
arguments: { url: server.HELLO_WORLD },
|
||||
});
|
||||
await client1.close();
|
||||
|
||||
const transport2 = new SSEClientTransport(url);
|
||||
const client2 = new Client({ name: 'test', version: '1.0.0' });
|
||||
await client2.connect(transport2);
|
||||
await client2.callTool({
|
||||
name: 'browser_navigate',
|
||||
arguments: { url: server.HELLO_WORLD },
|
||||
});
|
||||
await client2.close();
|
||||
|
||||
await expect(async () => {
|
||||
const lines = stderr().split('\n');
|
||||
expect(lines.filter(line => line.match(/create SSE session/)).length).toBe(2);
|
||||
expect(lines.filter(line => line.match(/delete SSE session/)).length).toBe(2);
|
||||
|
||||
expect(lines.filter(line => line.match(/create context/)).length).toBe(2);
|
||||
expect(lines.filter(line => line.match(/close context/)).length).toBe(2);
|
||||
|
||||
expect(lines.filter(line => line.match(/create browser context \(persistent\)/)).length).toBe(2);
|
||||
expect(lines.filter(line => line.match(/close browser context \(persistent\)/)).length).toBe(2);
|
||||
|
||||
expect(lines.filter(line => line.match(/lock user data dir/)).length).toBe(2);
|
||||
expect(lines.filter(line => line.match(/release user data dir/)).length).toBe(2);
|
||||
}).toPass();
|
||||
});
|
||||
|
||||
test('sse transport browser lifecycle (persistent, multiclient)', async ({ serverEndpoint, server }) => {
|
||||
const { url } = await serverEndpoint();
|
||||
|
||||
const transport1 = new SSEClientTransport(url);
|
||||
const client1 = new Client({ name: 'test', version: '1.0.0' });
|
||||
await client1.connect(transport1);
|
||||
await client1.callTool({
|
||||
name: 'browser_navigate',
|
||||
arguments: { url: server.HELLO_WORLD },
|
||||
});
|
||||
|
||||
const transport2 = new SSEClientTransport(url);
|
||||
const client2 = new Client({ name: 'test', version: '1.0.0' });
|
||||
await client2.connect(transport2);
|
||||
const response = await client2.callTool({
|
||||
name: 'browser_navigate',
|
||||
arguments: { url: server.HELLO_WORLD },
|
||||
});
|
||||
expect(response.isError).toBe(true);
|
||||
expect(response.content?.[0].text).toContain('use --isolated to run multiple instances of the same browser');
|
||||
|
||||
await client1.close();
|
||||
await client2.close();
|
||||
});
|
||||
|
||||
test('streamable http transport', async ({ serverEndpoint }) => {
|
||||
// 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 { url } = await serverEndpoint();
|
||||
const transport = new StreamableHTTPClientTransport(new URL('/mcp', url));
|
||||
const client = new Client({ name: 'test', version: '1.0.0' });
|
||||
await client.connect(transport);
|
||||
await client.ping();
|
||||
|
||||
@@ -14,8 +14,6 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { chromium } from 'playwright';
|
||||
|
||||
import { test, expect } from './fixtures.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 }) => {
|
||||
expect(await client.callTool({
|
||||
name: 'browser_tab_list',
|
||||
arguments: {},
|
||||
})).toHaveTextContent(`### Open tabs
|
||||
- 1: (current) [] (about:blank)`);
|
||||
});
|
||||
@@ -41,7 +38,6 @@ test('list first tab', async ({ client }) => {
|
||||
await createTab(client, 'Tab one', 'Body one');
|
||||
expect(await client.callTool({
|
||||
name: 'browser_tab_list',
|
||||
arguments: {},
|
||||
})).toHaveTextContent(`### Open tabs
|
||||
- 1: [] (about:blank)
|
||||
- 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 Snapshot
|
||||
\`\`\`yaml
|
||||
- generic [ref=s1e2]: Body one
|
||||
- generic [ref=e1]: Body one
|
||||
\`\`\``);
|
||||
|
||||
expect(await createTab(client, 'Tab two', 'Body two')).toHaveTextContent(`
|
||||
@@ -82,7 +78,7 @@ test('create new tab', async ({ client }) => {
|
||||
- Page Title: Tab two
|
||||
- Page Snapshot
|
||||
\`\`\`yaml
|
||||
- generic [ref=s1e2]: Body two
|
||||
- generic [ref=e1]: Body two
|
||||
\`\`\``);
|
||||
});
|
||||
|
||||
@@ -110,7 +106,7 @@ test('select tab', async ({ client }) => {
|
||||
- Page Title: Tab one
|
||||
- Page Snapshot
|
||||
\`\`\`yaml
|
||||
- generic [ref=s2e2]: Body one
|
||||
- generic [ref=e1]: Body one
|
||||
\`\`\``);
|
||||
});
|
||||
|
||||
@@ -137,21 +133,18 @@ test('close tab', async ({ client }) => {
|
||||
- Page Title: Tab one
|
||||
- Page Snapshot
|
||||
\`\`\`yaml
|
||||
- generic [ref=s2e2]: Body one
|
||||
- generic [ref=e1]: Body one
|
||||
\`\`\``);
|
||||
});
|
||||
|
||||
test('reuse first tab when navigating', async ({ startClient, cdpEndpoint }) => {
|
||||
const browser = await chromium.connectOverCDP(await cdpEndpoint());
|
||||
const [context] = browser.contexts();
|
||||
const pages = context.pages();
|
||||
test('reuse first tab when navigating', async ({ startClient, cdpServer, server }) => {
|
||||
const browserContext = await cdpServer.start();
|
||||
const pages = browserContext.pages();
|
||||
|
||||
const client = await startClient({ args: [`--cdp-endpoint=${await cdpEndpoint()}`] });
|
||||
const { client } = await startClient({ args: [`--cdp-endpoint=${cdpServer.endpoint}`] });
|
||||
await client.callTool({
|
||||
name: 'browser_navigate',
|
||||
arguments: {
|
||||
url: 'data:text/html,<title>Title</title><body>Body</body>',
|
||||
},
|
||||
arguments: { url: server.HELLO_WORLD },
|
||||
});
|
||||
|
||||
expect(pages.length).toBe(1);
|
||||
|
||||
@@ -38,6 +38,7 @@ export class TestServer {
|
||||
readonly PORT: number;
|
||||
readonly PREFIX: string;
|
||||
readonly CROSS_PROCESS_PREFIX: string;
|
||||
readonly HELLO_WORLD: string;
|
||||
|
||||
static async create(port: number): Promise<TestServer> {
|
||||
const server = new TestServer(port);
|
||||
@@ -67,8 +68,9 @@ export class TestServer {
|
||||
const same_origin = 'localhost';
|
||||
const protocol = sslOptions ? 'https' : 'http';
|
||||
this.PORT = port;
|
||||
this.PREFIX = `${protocol}://${same_origin}:${port}`;
|
||||
this.CROSS_PROCESS_PREFIX = `${protocol}://${cross_origin}:${port}`;
|
||||
this.PREFIX = `${protocol}://${same_origin}:${port}/`;
|
||||
this.CROSS_PROCESS_PREFIX = `${protocol}://${cross_origin}:${port}/`;
|
||||
this.HELLO_WORLD = `${this.PREFIX}hello-world`;
|
||||
}
|
||||
|
||||
setCSP(path: string, csp: string) {
|
||||
@@ -88,6 +90,13 @@ export class TestServer {
|
||||
this._routes.set(path, handler);
|
||||
}
|
||||
|
||||
setContent(path: string, content: string, mimeType: string) {
|
||||
this.route(path, (req, res) => {
|
||||
res.writeHead(200, { 'Content-Type': mimeType });
|
||||
res.end(mimeType === 'text/html' ? `<!DOCTYPE html>${content}` : content);
|
||||
});
|
||||
}
|
||||
|
||||
redirect(from: string, to: string) {
|
||||
this.route(from, (req, res) => {
|
||||
const headers = this._extraHeaders.get(req.url!) || {};
|
||||
@@ -120,6 +129,15 @@ export class TestServer {
|
||||
for (const subscriber of this._requestSubscribers.values())
|
||||
subscriber[rejectSymbol].call(null, error);
|
||||
this._requestSubscribers.clear();
|
||||
|
||||
this.setContent('/favicon.ico', '', 'image/x-icon');
|
||||
|
||||
this.setContent('/', ``, 'text/html');
|
||||
|
||||
this.setContent('/hello-world', `
|
||||
<title>Title</title>
|
||||
<body>Hello, world!</body>
|
||||
`, 'text/html');
|
||||
}
|
||||
|
||||
_onRequest(request: http.IncomingMessage, response: http.ServerResponse) {
|
||||
@@ -144,7 +162,11 @@ export class TestServer {
|
||||
this._requestSubscribers.delete(path);
|
||||
}
|
||||
const handler = this._routes.get(path);
|
||||
if (handler)
|
||||
if (handler) {
|
||||
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`);
|
||||
});
|
||||
@@ -1,7 +1,6 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ESNext",
|
||||
"skipLibCheck": true,
|
||||
"esModuleInterop": true,
|
||||
"moduleResolution": "nodenext",
|
||||
"strict": true,
|
||||
|
||||
@@ -28,159 +28,166 @@ import filesTools from '../lib/tools/files.js';
|
||||
import installTools from '../lib/tools/install.js';
|
||||
import keyboardTools from '../lib/tools/keyboard.js';
|
||||
import navigateTools from '../lib/tools/navigate.js';
|
||||
import networkTools from '../lib/tools/network.js';
|
||||
import pdfTools from '../lib/tools/pdf.js';
|
||||
import snapshotTools from '../lib/tools/snapshot.js';
|
||||
import tabsTools from '../lib/tools/tabs.js';
|
||||
import screenTools from '../lib/tools/screen.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 = {
|
||||
'Snapshot-based Interactions': [
|
||||
'Interactions': [
|
||||
...snapshotTools,
|
||||
],
|
||||
'Vision-based Interactions': [
|
||||
...screenTools
|
||||
],
|
||||
'Tab Management': [
|
||||
...tabsTools(true),
|
||||
...keyboardTools(true),
|
||||
...waitTools(true),
|
||||
...filesTools(true),
|
||||
...dialogsTools(true),
|
||||
],
|
||||
'Navigation': [
|
||||
...navigateTools(true),
|
||||
],
|
||||
'Keyboard': [
|
||||
...keyboardTools(true)
|
||||
],
|
||||
'Console': [
|
||||
...consoleTools
|
||||
],
|
||||
'Files and Media': [
|
||||
...filesTools(true),
|
||||
...pdfTools
|
||||
'Resources': [
|
||||
...screenshotTools,
|
||||
...pdfTools,
|
||||
...networkTools,
|
||||
...consoleTools,
|
||||
],
|
||||
'Utilities': [
|
||||
...commonTools(true),
|
||||
...installTools,
|
||||
...dialogsTools(true),
|
||||
...commonTools(true),
|
||||
],
|
||||
'Tabs': [
|
||||
...tabsTools(true),
|
||||
],
|
||||
'Testing': [
|
||||
...testTools,
|
||||
],
|
||||
'Vision mode': [
|
||||
...visionTools,
|
||||
...keyboardTools(),
|
||||
...waitTools(false),
|
||||
...filesTools(false),
|
||||
...dialogsTools(false),
|
||||
],
|
||||
};
|
||||
|
||||
// NOTE: Can be removed when we drop Node.js 18 support and changed to import.meta.filename.
|
||||
const __filename = url.fileURLToPath(import.meta.url);
|
||||
|
||||
const kStartMarker = `<!--- Generated by ${path.basename(__filename)} -->`;
|
||||
const kEndMarker = `<!--- End of generated section -->`;
|
||||
|
||||
/**
|
||||
* @param {ParsedToolSchema} tool
|
||||
* @returns {string}
|
||||
* @param {import('../src/tools/tool.js').ToolSchema<any>} tool
|
||||
* @returns {string[]}
|
||||
*/
|
||||
function formatToolForReadme(tool) {
|
||||
const lines = /** @type {string[]} */ ([]);
|
||||
lines.push(`<!-- NOTE: This has been generated via ${path.basename(__filename)} -->\n\n`);
|
||||
lines.push(`- **${tool.name}**\n`);
|
||||
lines.push(` - Description: ${tool.description}\n`);
|
||||
lines.push(`<!-- NOTE: This has been generated via ${path.basename(__filename)} -->`);
|
||||
lines.push(``);
|
||||
lines.push(`- **${tool.name}**`);
|
||||
lines.push(` - Title: ${tool.title}`);
|
||||
lines.push(` - Description: ${tool.description}`);
|
||||
|
||||
if (tool.parameters && tool.parameters.length > 0) {
|
||||
lines.push(` - Parameters:\n`);
|
||||
tool.parameters.forEach(param => {
|
||||
const inputSchema = /** @type {any} */ (zodToJsonSchema(tool.inputSchema || {}));
|
||||
const requiredParams = inputSchema.required || [];
|
||||
if (inputSchema.properties && Object.keys(inputSchema.properties).length) {
|
||||
lines.push(` - Parameters:`);
|
||||
Object.entries(inputSchema.properties).forEach(([name, param]) => {
|
||||
const optional = !requiredParams.includes(name);
|
||||
const meta = /** @type {string[]} */ ([]);
|
||||
if (param.type)
|
||||
meta.push(param.type);
|
||||
if (param.optional)
|
||||
if (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 {
|
||||
lines.push(` - Parameters: None\n`);
|
||||
lines.push(` - Parameters: None`);
|
||||
}
|
||||
|
||||
lines.push('\n');
|
||||
return lines.join('');
|
||||
lines.push(` - Read-only: **${tool.type === 'readOnly'}**`);
|
||||
lines.push('');
|
||||
return lines;
|
||||
}
|
||||
|
||||
/**
|
||||
* @typedef {{
|
||||
* name: any;
|
||||
* description: any;
|
||||
* parameters: {
|
||||
* name: string;
|
||||
* description: string;
|
||||
* optional: boolean;
|
||||
* type: string;
|
||||
* }[];
|
||||
*}} ParsedToolSchema
|
||||
* @param {string} content
|
||||
* @param {string} startMarker
|
||||
* @param {string} endMarker
|
||||
* @param {string[]} generatedLines
|
||||
* @returns {Promise<string>}
|
||||
*/
|
||||
async function updateSection(content, startMarker, endMarker, generatedLines) {
|
||||
const startMarkerIndex = content.indexOf(startMarker);
|
||||
const endMarkerIndex = content.indexOf(endMarker);
|
||||
if (startMarkerIndex === -1 || endMarkerIndex === -1)
|
||||
throw new Error('Markers for generated section not found in README');
|
||||
|
||||
/**
|
||||
* @param {import('../src/tools/tool').ToolSchema<any>} schema
|
||||
* @returns {ParsedToolSchema}
|
||||
*/
|
||||
function processToolSchema(schema) {
|
||||
const inputSchema = /** @type {import('zod-to-json-schema').JsonSchema7ObjectType} */ (zodToJsonSchema(schema.inputSchema || {}));
|
||||
if (inputSchema.type !== 'object')
|
||||
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
|
||||
};
|
||||
return [
|
||||
content.slice(0, startMarkerIndex + startMarker.length),
|
||||
'',
|
||||
generatedLines.join('\n'),
|
||||
'',
|
||||
content.slice(endMarkerIndex),
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
async function updateReadme() {
|
||||
/**
|
||||
* @param {string} content
|
||||
* @returns {Promise<string>}
|
||||
*/
|
||||
async function updateTools(content) {
|
||||
console.log('Loading tool information from compiled modules...');
|
||||
|
||||
// Count the tools processed
|
||||
const totalTools = Object.values(categories).flat().length;
|
||||
console.log(`Found ${totalTools} tools`);
|
||||
|
||||
const generatedLines = /** @type {string[]} */ ([]);
|
||||
|
||||
for (const [category, categoryTools] of Object.entries(categories)) {
|
||||
generatedLines.push(`### ${category}\n\n`);
|
||||
for (const tool of categoryTools) {
|
||||
const scheme = processToolSchema(tool.schema);
|
||||
generatedLines.push(formatToolForReadme(scheme));
|
||||
}
|
||||
generatedLines.push(`<details>\n<summary><b>${category}</b></summary>`);
|
||||
generatedLines.push('');
|
||||
for (const tool of categoryTools)
|
||||
generatedLines.push(...formatToolForReadme(tool.schema));
|
||||
generatedLines.push(`</details>`);
|
||||
generatedLines.push('');
|
||||
}
|
||||
|
||||
const startMarker = `<!--- Tools generated by ${path.basename(__filename)} -->`;
|
||||
const endMarker = `<!--- End of tools generated section -->`;
|
||||
return updateSection(content, startMarker, endMarker, generatedLines);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} content
|
||||
* @returns {Promise<string>}
|
||||
*/
|
||||
async function updateOptions(content) {
|
||||
console.log('Listing options...');
|
||||
const output = execSync('node cli.js --help');
|
||||
const lines = output.toString().split('\n');
|
||||
const firstLine = lines.findIndex(line => line.includes('--version'));
|
||||
lines.splice(0, firstLine + 1);
|
||||
const lastLine = lines.findIndex(line => line.includes('--help'));
|
||||
lines.splice(lastLine);
|
||||
const startMarker = `<!--- Options generated by ${path.basename(__filename)} -->`;
|
||||
const endMarker = `<!--- End of options generated section -->`;
|
||||
return updateSection(content, startMarker, endMarker, [
|
||||
'```',
|
||||
'> npx @playwright/mcp@latest --help',
|
||||
...lines,
|
||||
'```',
|
||||
]);
|
||||
}
|
||||
|
||||
async function updateReadme() {
|
||||
const readmePath = path.join(path.dirname(__filename), '..', 'README.md');
|
||||
const readmeContent = await fs.promises.readFile(readmePath, 'utf-8');
|
||||
const startMarker = readmeContent.indexOf(kStartMarker);
|
||||
const endMarker = readmeContent.indexOf(kEndMarker);
|
||||
if (startMarker === -1 || endMarker === -1)
|
||||
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');
|
||||
const withTools = await updateTools(readmeContent);
|
||||
const withOptions = await updateOptions(withTools);
|
||||
await fs.promises.writeFile(readmePath, withOptions, 'utf-8');
|
||||
console.log('README updated successfully');
|
||||
}
|
||||
|
||||
// Run the update
|
||||
updateReadme().catch(err => {
|
||||
console.error('Error updating README:', err);
|
||||
process.exit(1);
|
||||
|
||||
Reference in New Issue
Block a user