Compare commits
99 Commits
v0.0.29
...
copilot/fi
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f83a808d39 | ||
|
|
54754d8be1 | ||
|
|
21ced701b5 | ||
|
|
d3bf2eefc6 | ||
|
|
2ca899316d | ||
|
|
16f3523317 | ||
|
|
6c2dda31ad | ||
|
|
3b6ecf0a43 | ||
|
|
636f1956cc | ||
|
|
5aef2aafcb | ||
|
|
8ecc46c905 | ||
|
|
5dbb1504ba | ||
|
|
20e1144c3b | ||
|
|
eab20aa69e | ||
|
|
46ce86f97e | ||
|
|
4890b9d509 | ||
|
|
3f6837baa9 | ||
|
|
6d62c173c8 | ||
|
|
3c6eac9b21 | ||
|
|
41a44f7abc | ||
|
|
372395666a | ||
|
|
a60d7b8cd1 | ||
|
|
ffe0117456 | ||
|
|
7c07cc86eb | ||
|
|
3787439fc1 | ||
|
|
2a86ac74e3 | ||
|
|
6dd44923da | ||
|
|
f600234897 | ||
|
|
4df162aff5 | ||
|
|
65d99fe595 | ||
|
|
903c857f19 | ||
|
|
9b5f97b076 | ||
|
|
04988d8fac | ||
|
|
2bf57e22c6 | ||
|
|
dbf113d5e4 | ||
|
|
6710a78641 | ||
|
|
a9b9fb85da | ||
|
|
26a2a6fc83 | ||
|
|
e934d5e23e | ||
|
|
ecfa10448b | ||
|
|
e153ac3b7c | ||
|
|
e0fb748ccc | ||
|
|
c63b7823e1 | ||
|
|
bd34e9d7e9 | ||
|
|
c72d0320f4 | ||
|
|
da8a244f33 | ||
|
|
31a4fb3d07 | ||
|
|
bc120baa78 | ||
|
|
2c5eac89a8 | ||
|
|
288f1b863b | ||
|
|
53e3e37991 | ||
|
|
b1a0f775cf | ||
|
|
6320b08173 | ||
|
|
601a74305c | ||
|
|
c2b98dc70b | ||
|
|
70862ce456 | ||
|
|
468c84eb8f | ||
|
|
cfcca40b90 | ||
|
|
f1826b96b6 | ||
|
|
eeeab4f042 | ||
|
|
efe3ff0c7c | ||
|
|
e3df209b96 | ||
|
|
29711d07d3 | ||
|
|
b0be1ee256 | ||
|
|
d3867affed | ||
|
|
1eee30fd45 | ||
|
|
29ac29e6bb | ||
|
|
9f8441daa5 | ||
|
|
64f950ae42 | ||
|
|
5bfff0a059 | ||
|
|
c97bc6e2ae | ||
|
|
fe0c0ffffe | ||
|
|
9526910864 | ||
|
|
95454735bf | ||
|
|
e9f6433241 | ||
|
|
d61aa16fee | ||
|
|
012c906500 | ||
|
|
825a97d66e | ||
|
|
3061d9aa56 | ||
|
|
da818d113a | ||
|
|
a5a57df105 | ||
|
|
be8adb1866 | ||
|
|
c5a2324aaf | ||
|
|
128474b4aa | ||
|
|
7fca8f50f8 | ||
|
|
841bb417d1 | ||
|
|
59f1d67a4e | ||
|
|
1600ba6645 | ||
|
|
127c996e86 | ||
|
|
4bd39c07e9 | ||
|
|
f5b68dc590 | ||
|
|
875bd3b6ec | ||
|
|
137b74750c | ||
|
|
ded00dc422 | ||
|
|
5df6c2431b | ||
|
|
9066988098 | ||
|
|
1dc4977ff9 | ||
|
|
96e234012d | ||
|
|
6c3f3b6576 |
52
.github/workflows/ci.yml
vendored
52
.github/workflows/ci.yml
vendored
@@ -11,10 +11,10 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
- name: Use Node.js 18
|
- name: Use Node.js 20
|
||||||
uses: actions/setup-node@v4
|
uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node-version: '18'
|
node-version: '20'
|
||||||
cache: 'npm'
|
cache: 'npm'
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: npm ci
|
run: npm ci
|
||||||
@@ -32,11 +32,10 @@ jobs:
|
|||||||
runs-on: ${{ matrix.os }}
|
runs-on: ${{ matrix.os }}
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
- name: Use Node.js 18
|
- name: Use Node.js 20
|
||||||
uses: actions/setup-node@v4
|
uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
# https://github.com/microsoft/playwright-mcp/issues/344
|
node-version: '20'
|
||||||
node-version: '18.19'
|
|
||||||
cache: 'npm'
|
cache: 'npm'
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: npm ci
|
run: npm ci
|
||||||
@@ -55,10 +54,10 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
- name: Use Node.js 18
|
- name: Use Node.js 20
|
||||||
uses: actions/setup-node@v4
|
uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node-version: '18'
|
node-version: '20'
|
||||||
cache: 'npm'
|
cache: 'npm'
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: npm ci
|
run: npm ci
|
||||||
@@ -83,3 +82,42 @@ jobs:
|
|||||||
npm run test -- --project=chromium-docker
|
npm run test -- --project=chromium-docker
|
||||||
env:
|
env:
|
||||||
MCP_IN_DOCKER: 1
|
MCP_IN_DOCKER: 1
|
||||||
|
|
||||||
|
test_extension:
|
||||||
|
strategy:
|
||||||
|
fail-fast: false
|
||||||
|
runs-on: macos-latest
|
||||||
|
defaults:
|
||||||
|
run:
|
||||||
|
working-directory: ./extension
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- name: Use Node.js 20
|
||||||
|
uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: '20' # crypto.randomUUID(); stalls in v18.20.8
|
||||||
|
cache: 'npm'
|
||||||
|
- name: Install dependencies
|
||||||
|
run: npm ci
|
||||||
|
- name: Build extension
|
||||||
|
run: npm run build
|
||||||
|
- name: Upload artifact
|
||||||
|
uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: extension
|
||||||
|
path: ./extension/dist
|
||||||
|
retention-days: 7
|
||||||
|
- name: Install and build MCP server
|
||||||
|
run: |
|
||||||
|
cd ..
|
||||||
|
npm ci
|
||||||
|
npm run build
|
||||||
|
npx playwright install chromium
|
||||||
|
- name: Run tests
|
||||||
|
run: |
|
||||||
|
if [[ "$(uname)" == "Linux" ]]; then
|
||||||
|
xvfb-run --auto-servernum --server-args="-screen 0 1280x960x24" -- npm run test
|
||||||
|
else
|
||||||
|
npm run test
|
||||||
|
fi
|
||||||
|
shell: bash
|
||||||
|
|||||||
44
.github/workflows/copilot-setup-steps.yml
vendored
Normal file
44
.github/workflows/copilot-setup-steps.yml
vendored
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
name: "Copilot Setup Steps"
|
||||||
|
|
||||||
|
# Automatically run the setup steps when they are changed to allow for easy validation, and
|
||||||
|
# allow manual testing through the repository's "Actions" tab
|
||||||
|
on:
|
||||||
|
workflow_dispatch:
|
||||||
|
push:
|
||||||
|
paths:
|
||||||
|
- .github/workflows/copilot-setup-steps.yml
|
||||||
|
pull_request:
|
||||||
|
paths:
|
||||||
|
- .github/workflows/copilot-setup-steps.yml
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
# The job MUST be called `copilot-setup-steps` or it will not be picked up by Copilot.
|
||||||
|
copilot-setup-steps:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
|
# Set the permissions to the lowest permissions possible needed for your steps.
|
||||||
|
# Copilot will be given its own token for its operations.
|
||||||
|
permissions:
|
||||||
|
# If you want to clone the repository as part of your setup steps, for example to install dependencies, you'll need the `contents: read` permission. If you don't clone the repository in your setup steps, Copilot will do this for you automatically after the steps complete.
|
||||||
|
contents: read
|
||||||
|
|
||||||
|
# You can define any steps you want, and they will run before the agent starts.
|
||||||
|
# If you do not check out your code, Copilot will do this for you.
|
||||||
|
steps:
|
||||||
|
- name: Checkout code
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Set up Node.js
|
||||||
|
uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: "18.19"
|
||||||
|
cache: "npm"
|
||||||
|
|
||||||
|
- name: Install JavaScript dependencies
|
||||||
|
run: npm ci
|
||||||
|
|
||||||
|
- name: Playwright install
|
||||||
|
run: npx playwright install --with-deps
|
||||||
|
|
||||||
|
- name: Build
|
||||||
|
run: npm run build
|
||||||
15
.github/workflows/publish.yml
vendored
15
.github/workflows/publish.yml
vendored
@@ -44,6 +44,7 @@ jobs:
|
|||||||
- name: Login to ACR
|
- name: Login to ACR
|
||||||
run: az acr login --name playwright
|
run: az acr login --name playwright
|
||||||
- name: Build and push Docker image
|
- name: Build and push Docker image
|
||||||
|
id: build-push
|
||||||
uses: docker/build-push-action@v6
|
uses: docker/build-push-action@v6
|
||||||
with:
|
with:
|
||||||
context: .
|
context: .
|
||||||
@@ -53,3 +54,17 @@ jobs:
|
|||||||
tags: |
|
tags: |
|
||||||
playwright.azurecr.io/public/playwright/mcp:${{ github.event.release.tag_name }}
|
playwright.azurecr.io/public/playwright/mcp:${{ github.event.release.tag_name }}
|
||||||
playwright.azurecr.io/public/playwright/mcp:latest
|
playwright.azurecr.io/public/playwright/mcp:latest
|
||||||
|
- uses: oras-project/setup-oras@v1
|
||||||
|
- name: Set oras tags
|
||||||
|
run: |
|
||||||
|
attach_eol_manifest() {
|
||||||
|
local image="$1"
|
||||||
|
local today=$(date -u +'%Y-%m-%d')
|
||||||
|
# oras is re-using Docker credentials, so we don't need to login.
|
||||||
|
# Following the advice in https://portal.microsofticm.com/imp/v3/incidents/incident/476783820/summary
|
||||||
|
oras attach --artifact-type application/vnd.microsoft.artifact.lifecycle --annotation "vnd.microsoft.artifact.lifecycle.end-of-life.date=$today" $image
|
||||||
|
}
|
||||||
|
# for each tag, attach the eol manifest
|
||||||
|
for tag in $(echo ${{ steps.build-push.outputs.metadata['image.name'] }} | tr ',' '\n'); do
|
||||||
|
attach_eol_manifest $tag
|
||||||
|
done
|
||||||
|
|||||||
4
.gitignore
vendored
4
.gitignore
vendored
@@ -1,8 +1,10 @@
|
|||||||
lib/
|
lib/
|
||||||
|
dist/
|
||||||
node_modules/
|
node_modules/
|
||||||
test-results/
|
test-results/
|
||||||
playwright-report/
|
playwright-report/
|
||||||
.vscode/mcp.json
|
.vscode/mcp.json
|
||||||
|
|
||||||
.idea
|
.idea
|
||||||
.DS_Store
|
.DS_Store
|
||||||
|
.env
|
||||||
|
sessions/
|
||||||
|
|||||||
557
README.md
557
README.md
@@ -10,7 +10,7 @@ A Model Context Protocol (MCP) server that provides browser automation capabilit
|
|||||||
|
|
||||||
### Requirements
|
### Requirements
|
||||||
- Node.js 18 or newer
|
- Node.js 18 or newer
|
||||||
- VS Code, Cursor, Windsurf, Claude Desktop or any other MCP client
|
- VS Code, Cursor, Windsurf, Claude Desktop, Goose or any other MCP client
|
||||||
|
|
||||||
<!--
|
<!--
|
||||||
// Generate using:
|
// Generate using:
|
||||||
@@ -19,7 +19,9 @@ node utils/generate-links.js
|
|||||||
|
|
||||||
### Getting started
|
### Getting started
|
||||||
|
|
||||||
First, install the Playwright MCP server with your client. A typical configuration looks like this:
|
First, install the Playwright MCP server with your client.
|
||||||
|
|
||||||
|
**Standard config** works in most of the tools:
|
||||||
|
|
||||||
```js
|
```js
|
||||||
{
|
{
|
||||||
@@ -37,9 +39,85 @@ First, install the Playwright MCP server with your client. A typical configurati
|
|||||||
[<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)
|
[<img src="https://img.shields.io/badge/VS_Code-VS_Code?style=flat-square&label=Install%20Server&color=0098FF" alt="Install in VS Code">](https://insiders.vscode.dev/redirect?url=vscode%3Amcp%2Finstall%3F%257B%2522name%2522%253A%2522playwright%2522%252C%2522command%2522%253A%2522npx%2522%252C%2522args%2522%253A%255B%2522%2540playwright%252Fmcp%2540latest%2522%255D%257D) [<img alt="Install in VS Code Insiders" src="https://img.shields.io/badge/VS_Code_Insiders-VS_Code_Insiders?style=flat-square&label=Install%20Server&color=24bfa5">](https://insiders.vscode.dev/redirect?url=vscode-insiders%3Amcp%2Finstall%3F%257B%2522name%2522%253A%2522playwright%2522%252C%2522command%2522%253A%2522npx%2522%252C%2522args%2522%253A%255B%2522%2540playwright%252Fmcp%2540latest%2522%255D%257D)
|
||||||
|
|
||||||
|
|
||||||
<details><summary><b>Install in VS Code</b></summary>
|
<details>
|
||||||
|
<summary>Claude Code</summary>
|
||||||
|
|
||||||
You can also install the Playwright MCP server using the VS Code CLI:
|
Use the Claude Code CLI to add the Playwright MCP server:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
claude mcp add playwright npx @playwright/mcp@latest
|
||||||
|
```
|
||||||
|
</details>
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary>Claude Desktop</summary>
|
||||||
|
|
||||||
|
Follow the MCP install [guide](https://modelcontextprotocol.io/quickstart/user), use the standard config above.
|
||||||
|
|
||||||
|
</details>
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary>Cursor</summary>
|
||||||
|
|
||||||
|
#### Click the button to install:
|
||||||
|
|
||||||
|
[](cursor://anysphere.cursor-deeplink/mcp/install?name=Playwright&config=eyJjb21tYW5kIjoibnB4IEBwbGF5d3JpZ2h0L21jcEBsYXRlc3QifQ%3D%3D)
|
||||||
|
|
||||||
|
#### Or install manually:
|
||||||
|
|
||||||
|
Go to `Cursor Settings` -> `MCP` -> `Add new MCP Server`. Name to your liking, use `command` type with the command `npx @playwright/mcp`. You can also verify config or add command like arguments via clicking `Edit`.
|
||||||
|
|
||||||
|
</details>
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary>Gemini CLI</summary>
|
||||||
|
|
||||||
|
Follow the MCP install [guide](https://github.com/google-gemini/gemini-cli/blob/main/docs/tools/mcp-server.md#configure-the-mcp-server-in-settingsjson), use the standard config above.
|
||||||
|
|
||||||
|
</details>
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary>Goose</summary>
|
||||||
|
|
||||||
|
#### Click the button to install:
|
||||||
|
|
||||||
|
[](https://block.github.io/goose/extension?cmd=npx&arg=%40playwright%2Fmcp%40latest&id=playwright&name=Playwright&description=Interact%20with%20web%20pages%20through%20structured%20accessibility%20snapshots%20using%20Playwright)
|
||||||
|
|
||||||
|
#### Or install manually:
|
||||||
|
|
||||||
|
Go to `Advanced settings` -> `Extensions` -> `Add custom extension`. Name to your liking, use type `STDIO`, and set the `command` to `npx @playwright/mcp`. Click "Add Extension".
|
||||||
|
</details>
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary>LM Studio</summary>
|
||||||
|
|
||||||
|
#### Click the button to install:
|
||||||
|
|
||||||
|
[](https://lmstudio.ai/install-mcp?name=playwright&config=eyJjb21tYW5kIjoibnB4IiwiYXJncyI6WyJAcGxheXdyaWdodC9tY3BAbGF0ZXN0Il19)
|
||||||
|
|
||||||
|
#### Or install manually:
|
||||||
|
|
||||||
|
Go to `Program` in the right sidebar -> `Install` -> `Edit mcp.json`. Use the standard config above.
|
||||||
|
</details>
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary>Qodo Gen</summary>
|
||||||
|
|
||||||
|
Open [Qodo Gen](https://docs.qodo.ai/qodo-documentation/qodo-gen) chat panel in VSCode or IntelliJ → Connect more tools → + Add new MCP → Paste the standard config above.
|
||||||
|
|
||||||
|
Click <code>Save</code>.
|
||||||
|
</details>
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary>VS Code</summary>
|
||||||
|
|
||||||
|
#### Click the button to install:
|
||||||
|
|
||||||
|
[<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)
|
||||||
|
|
||||||
|
#### Or install manually:
|
||||||
|
|
||||||
|
Follow the MCP install [guide](https://code.visualstudio.com/docs/copilot/chat/mcp-servers#_add-an-mcp-server), use the standard config above. You can also install the Playwright MCP server using the VS Code CLI:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# For VS Code
|
# For VS Code
|
||||||
@@ -50,87 +128,10 @@ After installation, the Playwright MCP server will be available for use with you
|
|||||||
</details>
|
</details>
|
||||||
|
|
||||||
<details>
|
<details>
|
||||||
<summary><b>Install in Cursor</b></summary>
|
<summary>Windsurf</summary>
|
||||||
|
|
||||||
#### Click the button to install:
|
Follow Windsurf MCP [documentation](https://docs.windsurf.com/windsurf/cascade/mcp). Use the standard config above.
|
||||||
|
|
||||||
[](https://cursor.com/install-mcp?name=playwright&config=eyJjb21tYW5kIjoibnB4IEBwbGF5d3JpZ2h0L21jcEBsYXRlc3QifQ%3D%3D)
|
|
||||||
|
|
||||||
#### Or install manually:
|
|
||||||
|
|
||||||
Go to `Cursor Settings` -> `MCP` -> `Add new MCP Server`. Name to your liking, use `command` type with the command `npx @playwright/mcp`. You can also verify config or add command like arguments via clicking `Edit`.
|
|
||||||
|
|
||||||
```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>
|
|
||||||
|
|
||||||
<details>
|
|
||||||
<summary><b>Install in Qodo Gen</b></summary>
|
|
||||||
|
|
||||||
Open [Qodo Gen](https://docs.qodo.ai/qodo-documentation/qodo-gen) chat panel in VSCode or IntelliJ → Connect more tools → + Add new MCP → Paste the following configuration:
|
|
||||||
|
|
||||||
```js
|
|
||||||
{
|
|
||||||
"mcpServers": {
|
|
||||||
"playwright": {
|
|
||||||
"command": "npx",
|
|
||||||
"args": [
|
|
||||||
"@playwright/mcp@latest"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Click <code>Save</code>.
|
|
||||||
</details>
|
</details>
|
||||||
|
|
||||||
### Configuration
|
### Configuration
|
||||||
@@ -151,10 +152,8 @@ Playwright MCP server supports following arguments. They can be provided in the
|
|||||||
--block-service-workers block service workers
|
--block-service-workers block service workers
|
||||||
--browser <browser> browser or chrome channel to use, possible
|
--browser <browser> browser or chrome channel to use, possible
|
||||||
values: chrome, firefox, webkit, msedge.
|
values: chrome, firefox, webkit, msedge.
|
||||||
--browser-agent <endpoint> Use browser agent (experimental).
|
--caps <caps> comma-separated list of additional capabilities
|
||||||
--caps <caps> comma-separated list of capabilities to enable,
|
to enable, possible values: vision, pdf.
|
||||||
possible values: tabs, pdf, history, wait, files,
|
|
||||||
install. Default is all.
|
|
||||||
--cdp-endpoint <endpoint> CDP endpoint to connect to.
|
--cdp-endpoint <endpoint> CDP endpoint to connect to.
|
||||||
--config <path> path to the configuration file.
|
--config <path> path to the configuration file.
|
||||||
--device <device> device to emulate, for example: "iPhone 15"
|
--device <device> device to emulate, for example: "iPhone 15"
|
||||||
@@ -166,9 +165,7 @@ Playwright MCP server supports following arguments. They can be provided in the
|
|||||||
--isolated keep the browser profile in memory, do not save
|
--isolated keep the browser profile in memory, do not save
|
||||||
it to disk.
|
it to disk.
|
||||||
--image-responses <mode> whether to send image responses to the client.
|
--image-responses <mode> whether to send image responses to the client.
|
||||||
Can be "allow", "omit", or "auto". Defaults to
|
Can be "allow" or "omit", Defaults to "allow".
|
||||||
"auto", which sends images if the client can
|
|
||||||
display them.
|
|
||||||
--no-sandbox disable the sandbox for all process types that
|
--no-sandbox disable the sandbox for all process types that
|
||||||
are normally sandboxed.
|
are normally sandboxed.
|
||||||
--output-dir <path> path to the directory for output files.
|
--output-dir <path> path to the directory for output files.
|
||||||
@@ -177,6 +174,8 @@ Playwright MCP server supports following arguments. They can be provided in the
|
|||||||
example ".com,chromium.org,.domain.com"
|
example ".com,chromium.org,.domain.com"
|
||||||
--proxy-server <proxy> specify proxy server, for example
|
--proxy-server <proxy> specify proxy server, for example
|
||||||
"http://myproxy:3128" or "socks5://myproxy:8080"
|
"http://myproxy:3128" or "socks5://myproxy:8080"
|
||||||
|
--save-session Whether to save the Playwright MCP session into
|
||||||
|
the output directory.
|
||||||
--save-trace Whether to save the Playwright Trace of the
|
--save-trace Whether to save the Playwright Trace of the
|
||||||
session into the output directory.
|
session into the output directory.
|
||||||
--storage-state <path> path to the storage state file for isolated
|
--storage-state <path> path to the storage state file for isolated
|
||||||
@@ -186,8 +185,6 @@ Playwright MCP server supports following arguments. They can be provided in the
|
|||||||
specified, a temporary directory will be created.
|
specified, a temporary directory will be created.
|
||||||
--viewport-size <size> specify browser viewport size in pixels, for
|
--viewport-size <size> specify browser viewport size in pixels, for
|
||||||
example "1280, 720"
|
example "1280, 720"
|
||||||
--vision Run server that uses screenshots (Aria snapshots
|
|
||||||
are used by default)
|
|
||||||
```
|
```
|
||||||
|
|
||||||
<!--- End of options generated section -->
|
<!--- End of options generated section -->
|
||||||
@@ -288,21 +285,14 @@ npx @playwright/mcp@latest --config path/to/config.json
|
|||||||
host?: string; // Host to bind to (default: localhost)
|
host?: string; // Host to bind to (default: localhost)
|
||||||
},
|
},
|
||||||
|
|
||||||
// List of enabled capabilities
|
// List of additional capabilities
|
||||||
capabilities?: Array<
|
capabilities?: Array<
|
||||||
'core' | // Core browser automation
|
|
||||||
'tabs' | // Tab management
|
'tabs' | // Tab management
|
||||||
'pdf' | // PDF generation
|
|
||||||
'history' | // Browser history
|
|
||||||
'wait' | // Wait utilities
|
|
||||||
'files' | // File handling
|
|
||||||
'install' | // Browser installation
|
'install' | // Browser installation
|
||||||
'testing' // Testing
|
'pdf' | // PDF generation
|
||||||
|
'vision' | // Coordinate-based interactions
|
||||||
>;
|
>;
|
||||||
|
|
||||||
// Enable vision mode (screenshots instead of accessibility snapshots)
|
|
||||||
vision?: boolean;
|
|
||||||
|
|
||||||
// Directory for output files
|
// Directory for output files
|
||||||
outputDir?: string;
|
outputDir?: string;
|
||||||
|
|
||||||
@@ -316,9 +306,10 @@ npx @playwright/mcp@latest --config path/to/config.json
|
|||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Do not send image responses to the client.
|
* Whether to send image responses to the client. Can be "allow" or "omit".
|
||||||
|
* Defaults to "allow".
|
||||||
*/
|
*/
|
||||||
noImageResponses?: boolean;
|
imageResponses?: 'allow' | 'omit';
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
</details>
|
</details>
|
||||||
@@ -326,19 +317,19 @@ npx @playwright/mcp@latest --config path/to/config.json
|
|||||||
### Standalone MCP server
|
### Standalone MCP server
|
||||||
|
|
||||||
When running headed browser on system w/o display or from worker processes of the IDEs,
|
When running headed browser on system w/o display or from worker processes of the IDEs,
|
||||||
run the MCP server from environment with the DISPLAY and pass the `--port` flag to enable SSE transport.
|
run the MCP server from environment with the DISPLAY and pass the `--port` flag to enable HTTP transport.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
npx @playwright/mcp@latest --port 8931
|
npx @playwright/mcp@latest --port 8931
|
||||||
```
|
```
|
||||||
|
|
||||||
And then in MCP client config, set the `url` to the SSE endpoint:
|
And then in MCP client config, set the `url` to the HTTP endpoint:
|
||||||
|
|
||||||
```js
|
```js
|
||||||
{
|
{
|
||||||
"mcpServers": {
|
"mcpServers": {
|
||||||
"playwright": {
|
"playwright": {
|
||||||
"url": "http://localhost:8931/sse"
|
"url": "http://localhost:8931/mcp"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -391,42 +382,10 @@ http.createServer(async (req, res) => {
|
|||||||
|
|
||||||
### Tools
|
### Tools
|
||||||
|
|
||||||
The tools are available in two modes:
|
|
||||||
|
|
||||||
1. **Snapshot Mode** (default): Uses accessibility snapshots for better performance and reliability
|
|
||||||
2. **Vision Mode**: Uses screenshots for visual-based interactions
|
|
||||||
|
|
||||||
To use Vision Mode, add the `--vision` flag when starting the server:
|
|
||||||
|
|
||||||
```js
|
|
||||||
{
|
|
||||||
"mcpServers": {
|
|
||||||
"playwright": {
|
|
||||||
"command": "npx",
|
|
||||||
"args": [
|
|
||||||
"@playwright/mcp@latest",
|
|
||||||
"--vision"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
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 -->
|
<!--- Tools generated by update-readme.js -->
|
||||||
|
|
||||||
<details>
|
<details>
|
||||||
<summary><b>Interactions</b></summary>
|
<summary><b>Core automation</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 -->
|
<!-- NOTE: This has been generated via update-readme.js -->
|
||||||
|
|
||||||
@@ -436,10 +395,28 @@ X Y coordinate space, based on the provided screenshot.
|
|||||||
- Parameters:
|
- Parameters:
|
||||||
- `element` (string): Human-readable element description used to obtain permission to interact with the element
|
- `element` (string): Human-readable element description used to obtain permission to interact with the element
|
||||||
- `ref` (string): Exact target element reference from the page snapshot
|
- `ref` (string): Exact target element reference from the page snapshot
|
||||||
|
- `doubleClick` (boolean, optional): Whether to perform a double click instead of a single click
|
||||||
|
- `button` (string, optional): Button to click, defaults to left
|
||||||
- Read-only: **false**
|
- Read-only: **false**
|
||||||
|
|
||||||
<!-- NOTE: This has been generated via update-readme.js -->
|
<!-- 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_console_messages**
|
||||||
|
- Title: Get console messages
|
||||||
|
- Description: Returns all console messages
|
||||||
|
- Parameters: None
|
||||||
|
- Read-only: **true**
|
||||||
|
|
||||||
|
<!-- NOTE: This has been generated via update-readme.js -->
|
||||||
|
|
||||||
- **browser_drag**
|
- **browser_drag**
|
||||||
- Title: Drag mouse
|
- Title: Drag mouse
|
||||||
- Description: Perform drag and drop between two elements
|
- Description: Perform drag and drop between two elements
|
||||||
@@ -452,60 +429,17 @@ X Y coordinate space, based on the provided screenshot.
|
|||||||
|
|
||||||
<!-- NOTE: This has been generated via update-readme.js -->
|
<!-- NOTE: This has been generated via update-readme.js -->
|
||||||
|
|
||||||
- **browser_hover**
|
- **browser_evaluate**
|
||||||
- Title: Hover mouse
|
- Title: Evaluate JavaScript
|
||||||
- Description: Hover over element on page
|
- Description: Evaluate JavaScript expression on page or element
|
||||||
- Parameters:
|
- Parameters:
|
||||||
- `element` (string): Human-readable element description used to obtain permission to interact with the element
|
- `function` (string): () => { /* code */ } or (element) => { /* code */ } when element is provided
|
||||||
- `ref` (string): Exact target element reference from the page snapshot
|
- `element` (string, optional): Human-readable element description used to obtain permission to interact with the element
|
||||||
- Read-only: **true**
|
- `ref` (string, optional): Exact target element reference from the page snapshot
|
||||||
|
|
||||||
<!-- 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
|
|
||||||
- `ref` (string): Exact target element reference from the page snapshot
|
|
||||||
- `text` (string): Text to type into the element
|
|
||||||
- `submit` (boolean, optional): Whether to submit entered text (press Enter after)
|
|
||||||
- `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**
|
- Read-only: **false**
|
||||||
|
|
||||||
<!-- NOTE: This has been generated via update-readme.js -->
|
<!-- 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**
|
- **browser_file_upload**
|
||||||
- Title: Upload files
|
- Title: Upload files
|
||||||
- Description: Upload one or multiple files
|
- Description: Upload one or multiple files
|
||||||
@@ -523,10 +457,15 @@ X Y coordinate space, based on the provided screenshot.
|
|||||||
- `promptText` (string, optional): The text of the prompt in case of a prompt dialog.
|
- `promptText` (string, optional): The text of the prompt in case of a prompt dialog.
|
||||||
- Read-only: **false**
|
- Read-only: **false**
|
||||||
|
|
||||||
</details>
|
<!-- NOTE: This has been generated via update-readme.js -->
|
||||||
|
|
||||||
<details>
|
- **browser_hover**
|
||||||
<summary><b>Navigation</b></summary>
|
- 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 -->
|
<!-- NOTE: This has been generated via update-readme.js -->
|
||||||
|
|
||||||
@@ -553,32 +492,6 @@ X Y coordinate space, based on the provided screenshot.
|
|||||||
- Parameters: None
|
- Parameters: None
|
||||||
- Read-only: **true**
|
- 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**
|
|
||||||
|
|
||||||
<!-- 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 -->
|
<!-- NOTE: This has been generated via update-readme.js -->
|
||||||
|
|
||||||
- **browser_network_requests**
|
- **browser_network_requests**
|
||||||
@@ -589,35 +502,15 @@ X Y coordinate space, based on the provided screenshot.
|
|||||||
|
|
||||||
<!-- NOTE: This has been generated via update-readme.js -->
|
<!-- NOTE: This has been generated via update-readme.js -->
|
||||||
|
|
||||||
- **browser_console_messages**
|
- **browser_press_key**
|
||||||
- Title: Get console messages
|
- Title: Press a key
|
||||||
- Description: Returns all console messages
|
- Description: Press a key on the keyboard
|
||||||
- Parameters: None
|
- Parameters:
|
||||||
- Read-only: **true**
|
- `key` (string): Name of the key to press or a character to generate, such as `ArrowLeft` or `a`
|
||||||
|
|
||||||
</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**
|
- Read-only: **false**
|
||||||
|
|
||||||
<!-- NOTE: This has been generated via update-readme.js -->
|
<!-- 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**
|
- **browser_resize**
|
||||||
- Title: Resize browser window
|
- Title: Resize browser window
|
||||||
- Description: Resize the browser window
|
- Description: Resize the browser window
|
||||||
@@ -626,10 +519,75 @@ X Y coordinate space, based on the provided screenshot.
|
|||||||
- `height` (number): Height of the browser window
|
- `height` (number): Height of the browser window
|
||||||
- Read-only: **true**
|
- Read-only: **true**
|
||||||
|
|
||||||
|
<!-- 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_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_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:
|
||||||
|
- `type` (string, optional): Image format for the screenshot. Default is png.
|
||||||
|
- `filename` (string, optional): File name to save the screenshot to. Defaults to `page-{timestamp}.{png|jpeg}` if not specified.
|
||||||
|
- `element` (string, optional): Human-readable element description used to obtain permission to screenshot the element. If not provided, the screenshot will be taken of viewport. If element is provided, ref must be provided too.
|
||||||
|
- `ref` (string, optional): Exact target element reference from the page snapshot. If not provided, the screenshot will be taken of viewport. If ref is provided, element must be provided too.
|
||||||
|
- `fullPage` (boolean, optional): When true, takes a screenshot of the full scrollable page, instead of the currently visible viewport. Cannot be used with element screenshots.
|
||||||
|
- 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
|
||||||
|
- `ref` (string): Exact target element reference from the page snapshot
|
||||||
|
- `text` (string): Text to type into the element
|
||||||
|
- `submit` (boolean, optional): Whether to submit entered text (press Enter after)
|
||||||
|
- `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_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**
|
||||||
|
|
||||||
</details>
|
</details>
|
||||||
|
|
||||||
<details>
|
<details>
|
||||||
<summary><b>Tabs</b></summary>
|
<summary><b>Tab management</b></summary>
|
||||||
|
|
||||||
|
<!-- 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**
|
||||||
|
|
||||||
<!-- NOTE: This has been generated via update-readme.js -->
|
<!-- NOTE: This has been generated via update-readme.js -->
|
||||||
|
|
||||||
@@ -657,60 +615,29 @@ X Y coordinate space, based on the provided screenshot.
|
|||||||
- `index` (number): The index of the tab to select
|
- `index` (number): The index of the tab to select
|
||||||
- Read-only: **true**
|
- 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>
|
||||||
|
|
||||||
<details>
|
<details>
|
||||||
<summary><b>Testing</b></summary>
|
<summary><b>Browser installation</b></summary>
|
||||||
|
|
||||||
<!-- NOTE: This has been generated via update-readme.js -->
|
<!-- NOTE: This has been generated via update-readme.js -->
|
||||||
|
|
||||||
- **browser_generate_playwright_test**
|
- **browser_install**
|
||||||
- Title: Generate a Playwright test
|
- Title: Install the browser specified in the config
|
||||||
- Description: Generate a Playwright test for given scenario
|
- Description: Install the browser specified in the config. Call this if you get an error about the browser not being installed.
|
||||||
- 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
|
- Parameters: None
|
||||||
- Read-only: **true**
|
- Read-only: **false**
|
||||||
|
|
||||||
|
</details>
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary><b>Coordinate-based (opt-in via --caps=vision)</b></summary>
|
||||||
|
|
||||||
<!-- NOTE: This has been generated via update-readme.js -->
|
<!-- NOTE: This has been generated via update-readme.js -->
|
||||||
|
|
||||||
- **browser_screen_move_mouse**
|
- **browser_mouse_click_xy**
|
||||||
- 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
|
- Title: Click
|
||||||
- Description: Click left mouse button
|
- Description: Click left mouse button at a given position
|
||||||
- Parameters:
|
- Parameters:
|
||||||
- `element` (string): Human-readable element description used to obtain permission to interact with the element
|
- `element` (string): Human-readable element description used to obtain permission to interact with the element
|
||||||
- `x` (number): X coordinate
|
- `x` (number): X coordinate
|
||||||
@@ -719,9 +646,9 @@ X Y coordinate space, based on the provided screenshot.
|
|||||||
|
|
||||||
<!-- NOTE: This has been generated via update-readme.js -->
|
<!-- NOTE: This has been generated via update-readme.js -->
|
||||||
|
|
||||||
- **browser_screen_drag**
|
- **browser_mouse_drag_xy**
|
||||||
- Title: Drag mouse
|
- Title: Drag mouse
|
||||||
- Description: Drag left mouse button
|
- Description: Drag left mouse button to a given position
|
||||||
- Parameters:
|
- Parameters:
|
||||||
- `element` (string): Human-readable element description used to obtain permission to interact with the element
|
- `element` (string): Human-readable element description used to obtain permission to interact with the element
|
||||||
- `startX` (number): Start X coordinate
|
- `startX` (number): Start X coordinate
|
||||||
@@ -732,52 +659,28 @@ X Y coordinate space, based on the provided screenshot.
|
|||||||
|
|
||||||
<!-- NOTE: This has been generated via update-readme.js -->
|
<!-- NOTE: This has been generated via update-readme.js -->
|
||||||
|
|
||||||
- **browser_screen_type**
|
- **browser_mouse_move_xy**
|
||||||
- Title: Type text
|
- Title: Move mouse
|
||||||
- Description: Type text
|
- Description: Move mouse to a given position
|
||||||
- Parameters:
|
- Parameters:
|
||||||
- `text` (string): Text to type into the element
|
- `element` (string): Human-readable element description used to obtain permission to interact with the element
|
||||||
- `submit` (boolean, optional): Whether to submit entered text (press Enter after)
|
- `x` (number): X coordinate
|
||||||
- Read-only: **false**
|
- `y` (number): Y coordinate
|
||||||
|
|
||||||
<!-- 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**
|
- Read-only: **true**
|
||||||
|
|
||||||
<!-- NOTE: This has been generated via update-readme.js -->
|
</details>
|
||||||
|
|
||||||
- **browser_file_upload**
|
<details>
|
||||||
- Title: Upload files
|
<summary><b>PDF generation (opt-in via --caps=pdf)</b></summary>
|
||||||
- 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 -->
|
<!-- NOTE: This has been generated via update-readme.js -->
|
||||||
|
|
||||||
- **browser_handle_dialog**
|
- **browser_pdf_save**
|
||||||
- Title: Handle a dialog
|
- Title: Save as PDF
|
||||||
- Description: Handle a dialog
|
- Description: Save page as PDF
|
||||||
- Parameters:
|
- Parameters:
|
||||||
- `accept` (boolean): Whether to accept the dialog.
|
- `filename` (string, optional): File name to save the pdf to. Defaults to `page-{timestamp}.pdf` if not specified.
|
||||||
- `promptText` (string, optional): The text of the prompt in case of a prompt dialog.
|
- Read-only: **true**
|
||||||
- Read-only: **false**
|
|
||||||
|
|
||||||
</details>
|
</details>
|
||||||
|
|
||||||
|
|||||||
19
config.d.ts
vendored
19
config.d.ts
vendored
@@ -16,18 +16,13 @@
|
|||||||
|
|
||||||
import type * as playwright from 'playwright';
|
import type * as playwright from 'playwright';
|
||||||
|
|
||||||
export type ToolCapability = 'core' | 'tabs' | 'pdf' | 'history' | 'wait' | 'files' | 'install' | 'testing';
|
export type ToolCapability = 'core' | 'core-tabs' | 'core-install' | 'vision' | 'pdf';
|
||||||
|
|
||||||
export type Config = {
|
export type Config = {
|
||||||
/**
|
/**
|
||||||
* The browser to use.
|
* The browser to use.
|
||||||
*/
|
*/
|
||||||
browser?: {
|
browser?: {
|
||||||
/**
|
|
||||||
* Use browser agent (experimental).
|
|
||||||
*/
|
|
||||||
browserAgent?: string;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The type of browser to use.
|
* The type of browser to use.
|
||||||
*/
|
*/
|
||||||
@@ -85,19 +80,15 @@ export type Config = {
|
|||||||
/**
|
/**
|
||||||
* List of enabled tool capabilities. Possible values:
|
* List of enabled tool capabilities. Possible values:
|
||||||
* - 'core': Core browser automation features.
|
* - 'core': Core browser automation features.
|
||||||
* - 'tabs': Tab management features.
|
|
||||||
* - 'pdf': PDF generation and manipulation.
|
* - 'pdf': PDF generation and manipulation.
|
||||||
* - 'history': Browser history access.
|
* - 'vision': Coordinate-based interactions.
|
||||||
* - 'wait': Wait and timing utilities.
|
|
||||||
* - 'files': File upload/download support.
|
|
||||||
* - 'install': Browser installation utilities.
|
|
||||||
*/
|
*/
|
||||||
capabilities?: ToolCapability[];
|
capabilities?: ToolCapability[];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Run server that uses screenshots (Aria snapshots are used by default).
|
* Whether to save the Playwright session into the output directory.
|
||||||
*/
|
*/
|
||||||
vision?: boolean;
|
saveSession?: boolean;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Whether to save the Playwright trace of the session into the output directory.
|
* Whether to save the Playwright trace of the session into the output directory.
|
||||||
@@ -124,5 +115,5 @@ export type Config = {
|
|||||||
/**
|
/**
|
||||||
* 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.
|
* 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';
|
imageResponses?: 'allow' | 'omit';
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -192,6 +192,31 @@ const languageOptions = {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const importOrderRules = {
|
||||||
|
"import/order": [
|
||||||
|
2,
|
||||||
|
{
|
||||||
|
groups: [
|
||||||
|
"builtin",
|
||||||
|
"external",
|
||||||
|
"internal",
|
||||||
|
["parent", "sibling"],
|
||||||
|
"index",
|
||||||
|
"type",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
"import/consistent-type-specifier-style": [2, "prefer-top-level"],
|
||||||
|
};
|
||||||
|
|
||||||
|
const noFloatingPromisesRules = {
|
||||||
|
"@typescript-eslint/no-floating-promises": "error",
|
||||||
|
};
|
||||||
|
|
||||||
|
const noBooleanCompareRules = {
|
||||||
|
"@typescript-eslint/no-unnecessary-boolean-literal-compare": 2,
|
||||||
|
};
|
||||||
|
|
||||||
export default [
|
export default [
|
||||||
{
|
{
|
||||||
ignores: ["**/*.js"],
|
ignores: ["**/*.js"],
|
||||||
@@ -200,6 +225,11 @@ export default [
|
|||||||
files: ["**/*.ts", "**/*.tsx"],
|
files: ["**/*.ts", "**/*.tsx"],
|
||||||
plugins,
|
plugins,
|
||||||
languageOptions,
|
languageOptions,
|
||||||
rules: baseRules,
|
rules: {
|
||||||
|
...baseRules,
|
||||||
|
...importOrderRules,
|
||||||
|
...noFloatingPromisesRules,
|
||||||
|
...noBooleanCompareRules,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|||||||
BIN
extension/icons/icon-128.png
Normal file
BIN
extension/icons/icon-128.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 6.2 KiB |
BIN
extension/icons/icon-16.png
Normal file
BIN
extension/icons/icon-16.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 571 B |
BIN
extension/icons/icon-32.png
Normal file
BIN
extension/icons/icon-32.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.2 KiB |
BIN
extension/icons/icon-48.png
Normal file
BIN
extension/icons/icon-48.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 2.0 KiB |
40
extension/manifest.json
Normal file
40
extension/manifest.json
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
{
|
||||||
|
"manifest_version": 3,
|
||||||
|
"name": "Playwright MCP Bridge",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"description": "Share browser tabs with Playwright MCP server",
|
||||||
|
"key": "MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA9nMS2b0WCohjVHPGb8D9qAdkbIngDqoAjTeSccHJijgcONejge+OJxOQOMLu7b0ovt1c9BiEJa5JcpM+EHFVGL1vluBxK71zmBy1m2f9vZF3HG0LSCp7YRkum9rAIEthDwbkxx6XTvpmAY5rjFa/NON6b9Hlbo+8peUSkoOK7HTwYnnI36asZ9eUTiveIf+DMPLojW2UX33vDWG2UKvMVDewzclb4+uLxAYshY7Mx8we/b44xu+Anb/EBLKjOPk9Yh541xJ5Ozc8EiP/5yxOp9c/lRiYUHaRW+4r0HKZyFt0eZ52ti2iM4Nfk7jRXR7an3JPsUIf5deC/1cVM/+1ZQIDAQAB",
|
||||||
|
|
||||||
|
"permissions": [
|
||||||
|
"debugger",
|
||||||
|
"activeTab",
|
||||||
|
"tabs",
|
||||||
|
"storage"
|
||||||
|
],
|
||||||
|
|
||||||
|
"host_permissions": [
|
||||||
|
"<all_urls>"
|
||||||
|
],
|
||||||
|
|
||||||
|
"background": {
|
||||||
|
"service_worker": "lib/background.js",
|
||||||
|
"type": "module"
|
||||||
|
},
|
||||||
|
|
||||||
|
"action": {
|
||||||
|
"default_title": "Playwright MCP Bridge",
|
||||||
|
"default_icon": {
|
||||||
|
"16": "icons/icon-16.png",
|
||||||
|
"32": "icons/icon-32.png",
|
||||||
|
"48": "icons/icon-48.png",
|
||||||
|
"128": "icons/icon-128.png"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
"icons": {
|
||||||
|
"16": "icons/icon-16.png",
|
||||||
|
"32": "icons/icon-32.png",
|
||||||
|
"48": "icons/icon-48.png",
|
||||||
|
"128": "icons/icon-128.png"
|
||||||
|
}
|
||||||
|
}
|
||||||
1884
extension/package-lock.json
generated
Normal file
1884
extension/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
36
extension/package.json
Normal file
36
extension/package.json
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
{
|
||||||
|
"name": "@playwright/mcp-extension",
|
||||||
|
"version": "0.0.32",
|
||||||
|
"description": "Playwright MCP Browser Extension",
|
||||||
|
"type": "module",
|
||||||
|
"private": true,
|
||||||
|
"repository": {
|
||||||
|
"type": "git",
|
||||||
|
"url": "git+https://github.com/microsoft/playwright-mcp.git"
|
||||||
|
},
|
||||||
|
"homepage": "https://playwright.dev",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
},
|
||||||
|
"author": {
|
||||||
|
"name": "Microsoft Corporation"
|
||||||
|
},
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"scripts": {
|
||||||
|
"build": "tsc --project . && tsc --project tsconfig.ui.json && vite build",
|
||||||
|
"watch": "tsc --watch --project . & tsc --watch --project tsconfig.ui.json & vite build --watch",
|
||||||
|
"test": "playwright test",
|
||||||
|
"clean": "rm -rf dist"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/chrome": "^0.0.315",
|
||||||
|
"@types/react": "^18.2.66",
|
||||||
|
"@types/react-dom": "^18.2.22",
|
||||||
|
"@vitejs/plugin-react": "^4.0.0",
|
||||||
|
"react": "^18.2.0",
|
||||||
|
"react-dom": "^18.2.0",
|
||||||
|
"typescript": "^5.8.2",
|
||||||
|
"vite": "^5.0.0",
|
||||||
|
"vite-plugin-static-copy": "^3.1.1"
|
||||||
|
}
|
||||||
|
}
|
||||||
31
extension/playwright.config.ts
Normal file
31
extension/playwright.config.ts
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
/**
|
||||||
|
* 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 { defineConfig } from '@playwright/test';
|
||||||
|
|
||||||
|
import type { TestOptions } from '../tests/fixtures.js';
|
||||||
|
|
||||||
|
export default defineConfig<TestOptions>({
|
||||||
|
testDir: './tests',
|
||||||
|
fullyParallel: true,
|
||||||
|
forbidOnly: !!process.env.CI,
|
||||||
|
retries: process.env.CI ? 2 : 0,
|
||||||
|
workers: process.env.CI ? 1 : undefined,
|
||||||
|
reporter: 'list',
|
||||||
|
projects: [
|
||||||
|
{ name: 'chromium', use: { mcpBrowser: 'chromium' } },
|
||||||
|
],
|
||||||
|
});
|
||||||
219
extension/src/background.ts
Normal file
219
extension/src/background.ts
Normal file
@@ -0,0 +1,219 @@
|
|||||||
|
/**
|
||||||
|
* Copyright (c) Microsoft Corporation.
|
||||||
|
*
|
||||||
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
* you may not use this file except in compliance with the License.
|
||||||
|
* You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing, software
|
||||||
|
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
* See the License for the specific language governing permissions and
|
||||||
|
* limitations under the License.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { RelayConnection, debugLog } from './relayConnection.js';
|
||||||
|
|
||||||
|
type PageMessage = {
|
||||||
|
type: 'connectToMCPRelay';
|
||||||
|
mcpRelayUrl: string;
|
||||||
|
} | {
|
||||||
|
type: 'getTabs';
|
||||||
|
} | {
|
||||||
|
type: 'connectToTab';
|
||||||
|
tabId: number;
|
||||||
|
windowId: number;
|
||||||
|
mcpRelayUrl: string;
|
||||||
|
} | {
|
||||||
|
type: 'getConnectionStatus';
|
||||||
|
} | {
|
||||||
|
type: 'disconnect';
|
||||||
|
};
|
||||||
|
|
||||||
|
class TabShareExtension {
|
||||||
|
private _activeConnection: RelayConnection | undefined;
|
||||||
|
private _connectedTabId: number | null = null;
|
||||||
|
private _pendingTabSelection = new Map<number, { connection: RelayConnection, timerId?: number }>();
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
chrome.tabs.onRemoved.addListener(this._onTabRemoved.bind(this));
|
||||||
|
chrome.tabs.onUpdated.addListener(this._onTabUpdated.bind(this));
|
||||||
|
chrome.tabs.onActivated.addListener(this._onTabActivated.bind(this));
|
||||||
|
chrome.runtime.onMessage.addListener(this._onMessage.bind(this));
|
||||||
|
chrome.action.onClicked.addListener(this._onActionClicked.bind(this));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Promise-based message handling is not supported in Chrome: https://issues.chromium.org/issues/40753031
|
||||||
|
private _onMessage(message: PageMessage, sender: chrome.runtime.MessageSender, sendResponse: (response: any) => void) {
|
||||||
|
switch (message.type) {
|
||||||
|
case 'connectToMCPRelay':
|
||||||
|
this._connectToRelay(sender.tab!.id!, message.mcpRelayUrl!).then(
|
||||||
|
() => sendResponse({ success: true }),
|
||||||
|
(error: any) => sendResponse({ success: false, error: error.message }));
|
||||||
|
return true;
|
||||||
|
case 'getTabs':
|
||||||
|
this._getTabs().then(
|
||||||
|
tabs => sendResponse({ success: true, tabs, currentTabId: sender.tab?.id }),
|
||||||
|
(error: any) => sendResponse({ success: false, error: error.message }));
|
||||||
|
return true;
|
||||||
|
case 'connectToTab':
|
||||||
|
this._connectTab(sender.tab!.id!, message.tabId, message.windowId, message.mcpRelayUrl!).then(
|
||||||
|
() => sendResponse({ success: true }),
|
||||||
|
(error: any) => sendResponse({ success: false, error: error.message }));
|
||||||
|
return true; // Return true to indicate that the response will be sent asynchronously
|
||||||
|
case 'getConnectionStatus':
|
||||||
|
sendResponse({
|
||||||
|
connectedTabId: this._connectedTabId
|
||||||
|
});
|
||||||
|
return false;
|
||||||
|
case 'disconnect':
|
||||||
|
this._disconnect().then(
|
||||||
|
() => sendResponse({ success: true }),
|
||||||
|
(error: any) => sendResponse({ success: false, error: error.message }));
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async _connectToRelay(selectorTabId: number, mcpRelayUrl: string): Promise<void> {
|
||||||
|
try {
|
||||||
|
debugLog(`Connecting to relay at ${mcpRelayUrl}`);
|
||||||
|
const socket = new WebSocket(mcpRelayUrl);
|
||||||
|
await new Promise<void>((resolve, reject) => {
|
||||||
|
socket.onopen = () => resolve();
|
||||||
|
socket.onerror = () => reject(new Error('WebSocket error'));
|
||||||
|
setTimeout(() => reject(new Error('Connection timeout')), 5000);
|
||||||
|
});
|
||||||
|
|
||||||
|
const connection = new RelayConnection(socket);
|
||||||
|
connection.onclose = () => {
|
||||||
|
debugLog('Connection closed');
|
||||||
|
this._pendingTabSelection.delete(selectorTabId);
|
||||||
|
// TODO: show error in the selector tab?
|
||||||
|
};
|
||||||
|
this._pendingTabSelection.set(selectorTabId, { connection });
|
||||||
|
debugLog(`Connected to MCP relay`);
|
||||||
|
} catch (error: any) {
|
||||||
|
debugLog(`Failed to connect to MCP relay:`, error.message);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async _connectTab(selectorTabId: number, tabId: number, windowId: number, mcpRelayUrl: string): Promise<void> {
|
||||||
|
try {
|
||||||
|
debugLog(`Connecting tab ${tabId} to relay at ${mcpRelayUrl}`);
|
||||||
|
try {
|
||||||
|
this._activeConnection?.close('Another connection is requested');
|
||||||
|
} catch (error: any) {
|
||||||
|
debugLog(`Error closing active connection:`, error);
|
||||||
|
}
|
||||||
|
await this._setConnectedTabId(null);
|
||||||
|
|
||||||
|
this._activeConnection = this._pendingTabSelection.get(selectorTabId)?.connection;
|
||||||
|
if (!this._activeConnection)
|
||||||
|
throw new Error('No active MCP relay connection');
|
||||||
|
this._pendingTabSelection.delete(selectorTabId);
|
||||||
|
|
||||||
|
this._activeConnection.setTabId(tabId);
|
||||||
|
this._activeConnection.onclose = () => {
|
||||||
|
debugLog('MCP connection closed');
|
||||||
|
this._activeConnection = undefined;
|
||||||
|
void this._setConnectedTabId(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
await Promise.all([
|
||||||
|
this._setConnectedTabId(tabId),
|
||||||
|
chrome.tabs.update(tabId, { active: true }),
|
||||||
|
chrome.windows.update(windowId, { focused: true }),
|
||||||
|
]);
|
||||||
|
debugLog(`Connected to MCP bridge`);
|
||||||
|
} catch (error: any) {
|
||||||
|
await this._setConnectedTabId(null);
|
||||||
|
debugLog(`Failed to connect tab ${tabId}:`, error.message);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async _setConnectedTabId(tabId: number | null): Promise<void> {
|
||||||
|
const oldTabId = this._connectedTabId;
|
||||||
|
this._connectedTabId = tabId;
|
||||||
|
if (oldTabId && oldTabId !== tabId)
|
||||||
|
await this._updateBadge(oldTabId, { text: '' });
|
||||||
|
if (tabId)
|
||||||
|
await this._updateBadge(tabId, { text: '✓', color: '#4CAF50', title: 'Connected to MCP client' });
|
||||||
|
}
|
||||||
|
|
||||||
|
private async _updateBadge(tabId: number, { text, color, title }: { text: string; color?: string, title?: string }): Promise<void> {
|
||||||
|
try {
|
||||||
|
await chrome.action.setBadgeText({ tabId, text });
|
||||||
|
await chrome.action.setTitle({ tabId, title: title || '' });
|
||||||
|
if (color)
|
||||||
|
await chrome.action.setBadgeBackgroundColor({ tabId, color });
|
||||||
|
} catch (error: any) {
|
||||||
|
// Ignore errors as the tab may be closed already.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async _onTabRemoved(tabId: number): Promise<void> {
|
||||||
|
const pendingConnection = this._pendingTabSelection.get(tabId)?.connection;
|
||||||
|
if (pendingConnection) {
|
||||||
|
this._pendingTabSelection.delete(tabId);
|
||||||
|
pendingConnection.close('Browser tab closed');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (this._connectedTabId !== tabId)
|
||||||
|
return;
|
||||||
|
this._activeConnection?.close('Browser tab closed');
|
||||||
|
this._activeConnection = undefined;
|
||||||
|
this._connectedTabId = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private _onTabActivated(activeInfo: chrome.tabs.TabActiveInfo) {
|
||||||
|
for (const [tabId, pending] of this._pendingTabSelection) {
|
||||||
|
if (tabId === activeInfo.tabId) {
|
||||||
|
if (pending.timerId) {
|
||||||
|
clearTimeout(pending.timerId);
|
||||||
|
pending.timerId = undefined;
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (!pending.timerId) {
|
||||||
|
pending.timerId = setTimeout(() => {
|
||||||
|
const existed = this._pendingTabSelection.delete(tabId);
|
||||||
|
if (existed) {
|
||||||
|
pending.connection.close('Tab has been inactive for 5 seconds');
|
||||||
|
chrome.tabs.sendMessage(tabId, { type: 'connectionTimeout' });
|
||||||
|
}
|
||||||
|
}, 5000);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private _onTabUpdated(tabId: number, changeInfo: chrome.tabs.TabChangeInfo, tab: chrome.tabs.Tab) {
|
||||||
|
if (changeInfo.status === 'complete' && this._connectedTabId === tabId)
|
||||||
|
void this._setConnectedTabId(tabId);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async _getTabs(): Promise<chrome.tabs.Tab[]> {
|
||||||
|
const tabs = await chrome.tabs.query({});
|
||||||
|
return tabs.filter(tab => tab.url && !['chrome:', 'edge:', 'devtools:'].some(scheme => tab.url!.startsWith(scheme)));
|
||||||
|
}
|
||||||
|
|
||||||
|
private async _onActionClicked(): Promise<void> {
|
||||||
|
await chrome.tabs.create({
|
||||||
|
url: chrome.runtime.getURL('status.html'),
|
||||||
|
active: true
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private async _disconnect(): Promise<void> {
|
||||||
|
this._activeConnection?.close('User disconnected');
|
||||||
|
this._activeConnection = undefined;
|
||||||
|
await this._setConnectedTabId(null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
new TabShareExtension();
|
||||||
178
extension/src/relayConnection.ts
Normal file
178
extension/src/relayConnection.ts
Normal file
@@ -0,0 +1,178 @@
|
|||||||
|
/**
|
||||||
|
* Copyright (c) Microsoft Corporation.
|
||||||
|
*
|
||||||
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
* you may not use this file except in compliance with the License.
|
||||||
|
* You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing, software
|
||||||
|
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
* See the License for the specific language governing permissions and
|
||||||
|
* limitations under the License.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export function debugLog(...args: unknown[]): void {
|
||||||
|
const enabled = true;
|
||||||
|
if (enabled) {
|
||||||
|
// eslint-disable-next-line no-console
|
||||||
|
console.log('[Extension]', ...args);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type ProtocolCommand = {
|
||||||
|
id: number;
|
||||||
|
method: string;
|
||||||
|
params?: any;
|
||||||
|
};
|
||||||
|
|
||||||
|
type ProtocolResponse = {
|
||||||
|
id?: number;
|
||||||
|
method?: string;
|
||||||
|
params?: any;
|
||||||
|
result?: any;
|
||||||
|
error?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export class RelayConnection {
|
||||||
|
private _debuggee: chrome.debugger.Debuggee;
|
||||||
|
private _ws: WebSocket;
|
||||||
|
private _eventListener: (source: chrome.debugger.DebuggerSession, method: string, params: any) => void;
|
||||||
|
private _detachListener: (source: chrome.debugger.Debuggee, reason: string) => void;
|
||||||
|
private _tabPromise: Promise<void>;
|
||||||
|
private _tabPromiseResolve!: () => void;
|
||||||
|
private _closed = false;
|
||||||
|
|
||||||
|
onclose?: () => void;
|
||||||
|
|
||||||
|
constructor(ws: WebSocket) {
|
||||||
|
this._debuggee = { };
|
||||||
|
this._tabPromise = new Promise(resolve => this._tabPromiseResolve = resolve);
|
||||||
|
this._ws = ws;
|
||||||
|
this._ws.onmessage = this._onMessage.bind(this);
|
||||||
|
this._ws.onclose = () => this._onClose();
|
||||||
|
// Store listeners for cleanup
|
||||||
|
this._eventListener = this._onDebuggerEvent.bind(this);
|
||||||
|
this._detachListener = this._onDebuggerDetach.bind(this);
|
||||||
|
chrome.debugger.onEvent.addListener(this._eventListener);
|
||||||
|
chrome.debugger.onDetach.addListener(this._detachListener);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Either setTabId or close is called after creating the connection.
|
||||||
|
setTabId(tabId: number): void {
|
||||||
|
this._debuggee = { tabId };
|
||||||
|
this._tabPromiseResolve();
|
||||||
|
}
|
||||||
|
|
||||||
|
close(message: string): void {
|
||||||
|
this._ws.close(1000, message);
|
||||||
|
// ws.onclose is called asynchronously, so we call it here to avoid forwarding
|
||||||
|
// CDP events to the closed connection.
|
||||||
|
this._onClose();
|
||||||
|
}
|
||||||
|
|
||||||
|
private _onClose() {
|
||||||
|
if (this._closed)
|
||||||
|
return;
|
||||||
|
this._closed = true;
|
||||||
|
chrome.debugger.onEvent.removeListener(this._eventListener);
|
||||||
|
chrome.debugger.onDetach.removeListener(this._detachListener);
|
||||||
|
chrome.debugger.detach(this._debuggee).catch(() => {});
|
||||||
|
this.onclose?.();
|
||||||
|
}
|
||||||
|
|
||||||
|
private _onDebuggerEvent(source: chrome.debugger.DebuggerSession, method: string, params: any): void {
|
||||||
|
if (source.tabId !== this._debuggee.tabId)
|
||||||
|
return;
|
||||||
|
debugLog('Forwarding CDP event:', method, params);
|
||||||
|
const sessionId = source.sessionId;
|
||||||
|
this._sendMessage({
|
||||||
|
method: 'forwardCDPEvent',
|
||||||
|
params: {
|
||||||
|
sessionId,
|
||||||
|
method,
|
||||||
|
params,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private _onDebuggerDetach(source: chrome.debugger.Debuggee, reason: string): void {
|
||||||
|
if (source.tabId !== this._debuggee.tabId)
|
||||||
|
return;
|
||||||
|
this.close(`Debugger detached: ${reason}`);
|
||||||
|
this._debuggee = { };
|
||||||
|
}
|
||||||
|
|
||||||
|
private _onMessage(event: MessageEvent): void {
|
||||||
|
this._onMessageAsync(event).catch(e => debugLog('Error handling message:', e));
|
||||||
|
}
|
||||||
|
|
||||||
|
private async _onMessageAsync(event: MessageEvent): Promise<void> {
|
||||||
|
let message: ProtocolCommand;
|
||||||
|
try {
|
||||||
|
message = JSON.parse(event.data);
|
||||||
|
} catch (error: any) {
|
||||||
|
debugLog('Error parsing message:', error);
|
||||||
|
this._sendError(-32700, `Error parsing message: ${error.message}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
debugLog('Received message:', message);
|
||||||
|
|
||||||
|
const response: ProtocolResponse = {
|
||||||
|
id: message.id,
|
||||||
|
};
|
||||||
|
try {
|
||||||
|
response.result = await this._handleCommand(message);
|
||||||
|
} catch (error: any) {
|
||||||
|
debugLog('Error handling command:', error);
|
||||||
|
response.error = error.message;
|
||||||
|
}
|
||||||
|
debugLog('Sending response:', response);
|
||||||
|
this._sendMessage(response);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async _handleCommand(message: ProtocolCommand): Promise<any> {
|
||||||
|
if (message.method === 'attachToTab') {
|
||||||
|
await this._tabPromise;
|
||||||
|
debugLog('Attaching debugger to tab:', this._debuggee);
|
||||||
|
await chrome.debugger.attach(this._debuggee, '1.3');
|
||||||
|
const result: any = await chrome.debugger.sendCommand(this._debuggee, 'Target.getTargetInfo');
|
||||||
|
return {
|
||||||
|
targetInfo: result?.targetInfo,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (!this._debuggee.tabId)
|
||||||
|
throw new Error('No tab is connected. Please go to the Playwright MCP extension and select the tab you want to connect to.');
|
||||||
|
if (message.method === 'forwardCDPCommand') {
|
||||||
|
const { sessionId, method, params } = message.params;
|
||||||
|
debugLog('CDP command:', method, params);
|
||||||
|
const debuggerSession: chrome.debugger.DebuggerSession = {
|
||||||
|
...this._debuggee,
|
||||||
|
sessionId,
|
||||||
|
};
|
||||||
|
// Forward CDP command to chrome.debugger
|
||||||
|
return await chrome.debugger.sendCommand(
|
||||||
|
debuggerSession,
|
||||||
|
method,
|
||||||
|
params
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private _sendError(code: number, message: string): void {
|
||||||
|
this._sendMessage({
|
||||||
|
error: {
|
||||||
|
code,
|
||||||
|
message,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private _sendMessage(message: any): void {
|
||||||
|
if (this._ws.readyState === WebSocket.OPEN)
|
||||||
|
this._ws.send(JSON.stringify(message));
|
||||||
|
}
|
||||||
|
}
|
||||||
195
extension/src/ui/connect.css
Normal file
195
extension/src/ui/connect.css
Normal file
@@ -0,0 +1,195 @@
|
|||||||
|
/*
|
||||||
|
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.
|
||||||
|
*/
|
||||||
|
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Base styles */
|
||||||
|
.app-container {
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "Noto Sans", Helvetica, Arial, sans-serif;
|
||||||
|
background-color: #ffffff;
|
||||||
|
color: #1f2328;
|
||||||
|
margin: 0;
|
||||||
|
padding: 16px;
|
||||||
|
min-height: 100vh;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content-wrapper {
|
||||||
|
max-width: 600px;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Status Banner */
|
||||||
|
.status-container {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
padding-right: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-banner {
|
||||||
|
padding: 12px;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 500;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-banner.connected {
|
||||||
|
color: #1f2328;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-banner.connected::before {
|
||||||
|
content: "\2705";
|
||||||
|
margin-right: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-banner.error {
|
||||||
|
color: #1f2328;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-banner.error::before {
|
||||||
|
content: "\274C";
|
||||||
|
margin-right: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Buttons */
|
||||||
|
.button-container {
|
||||||
|
margin-bottom: 16px;
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
padding-right: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.button {
|
||||||
|
padding: 8px 16px;
|
||||||
|
border-radius: 6px;
|
||||||
|
border: none;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 500;
|
||||||
|
cursor: pointer;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
text-decoration: none;
|
||||||
|
margin-right: 8px;
|
||||||
|
min-width: 90px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.button.primary {
|
||||||
|
background-color: #f8f9fa;
|
||||||
|
color: #3c4043;
|
||||||
|
border: 1px solid #dadce0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.button.primary:hover {
|
||||||
|
background-color: #f1f3f4;
|
||||||
|
border-color: #dadce0;
|
||||||
|
box-shadow: 0 1px 2px 0 rgba(60,64,67,.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.button.default {
|
||||||
|
background-color: #f6f8fa;
|
||||||
|
color: #24292f;
|
||||||
|
}
|
||||||
|
|
||||||
|
.button.default:hover {
|
||||||
|
background-color: #f3f4f6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.button.reject {
|
||||||
|
background-color: #da3633;
|
||||||
|
color: #ffffff;
|
||||||
|
border: 1px solid #da3633;
|
||||||
|
}
|
||||||
|
|
||||||
|
.button.reject:hover {
|
||||||
|
background-color: #c73836;
|
||||||
|
border-color: #c73836;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Tab selection */
|
||||||
|
.tab-section-title {
|
||||||
|
padding-left: 12px;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 400;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
color: #656d76;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 12px;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
background-color: #ffffff;
|
||||||
|
cursor: pointer;
|
||||||
|
border-radius: 6px;
|
||||||
|
transition: background-color 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-item:hover {
|
||||||
|
background-color: #f8f9fa;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-item.selected {
|
||||||
|
background-color: #f6f8fa;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-item.disabled {
|
||||||
|
cursor: not-allowed;
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-radio {
|
||||||
|
margin-right: 12px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-favicon {
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
margin-right: 8px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-content {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-title {
|
||||||
|
font-weight: 500;
|
||||||
|
color: #1f2328;
|
||||||
|
margin-bottom: 2px;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-url {
|
||||||
|
font-size: 12px;
|
||||||
|
color: #656d76;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
29
extension/src/ui/connect.html
Normal file
29
extension/src/ui/connect.html
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
<!--
|
||||||
|
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.
|
||||||
|
-->
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<title>Playwright MCP extension</title>
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<link rel="icon" type="image/png" sizes="32x32" href="../../icons/icon-32.png">
|
||||||
|
<link rel="icon" type="image/png" sizes="16x16" href="../../icons/icon-16.png">
|
||||||
|
<link rel="stylesheet" href="connect.css">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="connect.tsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
168
extension/src/ui/connect.tsx
Normal file
168
extension/src/ui/connect.tsx
Normal file
@@ -0,0 +1,168 @@
|
|||||||
|
/**
|
||||||
|
* Copyright (c) Microsoft Corporation.
|
||||||
|
*
|
||||||
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
* you may not use this file except in compliance with the License.
|
||||||
|
* You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing, software
|
||||||
|
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
* See the License for the specific language governing permissions and
|
||||||
|
* limitations under the License.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React, { useState, useEffect, useCallback } from 'react';
|
||||||
|
import { createRoot } from 'react-dom/client';
|
||||||
|
import { Button, TabItem } from './tabItem.js';
|
||||||
|
import type { TabInfo } from './tabItem.js';
|
||||||
|
|
||||||
|
type StatusType = 'connected' | 'error' | 'connecting';
|
||||||
|
|
||||||
|
const ConnectApp: React.FC = () => {
|
||||||
|
const [tabs, setTabs] = useState<TabInfo[]>([]);
|
||||||
|
const [status, setStatus] = useState<{ type: StatusType; message: string } | null>(null);
|
||||||
|
const [showButtons, setShowButtons] = useState(true);
|
||||||
|
const [showTabList, setShowTabList] = useState(true);
|
||||||
|
const [clientInfo, setClientInfo] = useState('unknown');
|
||||||
|
const [mcpRelayUrl, setMcpRelayUrl] = useState('');
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const params = new URLSearchParams(window.location.search);
|
||||||
|
const relayUrl = params.get('mcpRelayUrl');
|
||||||
|
|
||||||
|
if (!relayUrl) {
|
||||||
|
setShowButtons(false);
|
||||||
|
setStatus({ type: 'error', message: 'Missing mcpRelayUrl parameter in URL.' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setMcpRelayUrl(relayUrl);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const client = JSON.parse(params.get('client') || '{}');
|
||||||
|
const info = `${client.name}/${client.version}`;
|
||||||
|
setClientInfo(info);
|
||||||
|
setStatus({
|
||||||
|
type: 'connecting',
|
||||||
|
message: `🎭 Playwright MCP started from "${info}" is trying to connect. Do you want to continue?`
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
setStatus({ type: 'error', message: 'Failed to parse client version.' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
void connectToMCPRelay(relayUrl);
|
||||||
|
void loadTabs();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const connectToMCPRelay = useCallback(async (mcpRelayUrl: string) => {
|
||||||
|
const response = await chrome.runtime.sendMessage({ type: 'connectToMCPRelay', mcpRelayUrl });
|
||||||
|
if (!response.success)
|
||||||
|
setStatus({ type: 'error', message: 'Failed to connect to MCP relay: ' + response.error });
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const loadTabs = useCallback(async () => {
|
||||||
|
const response = await chrome.runtime.sendMessage({ type: 'getTabs' });
|
||||||
|
if (response.success)
|
||||||
|
setTabs(response.tabs);
|
||||||
|
else
|
||||||
|
setStatus({ type: 'error', message: 'Failed to load tabs: ' + response.error });
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleConnectToTab = useCallback(async (tab: TabInfo) => {
|
||||||
|
setShowButtons(false);
|
||||||
|
setShowTabList(false);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await chrome.runtime.sendMessage({
|
||||||
|
type: 'connectToTab',
|
||||||
|
mcpRelayUrl,
|
||||||
|
tabId: tab.id,
|
||||||
|
windowId: tab.windowId,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response?.success) {
|
||||||
|
setStatus({ type: 'connected', message: `MCP client "${clientInfo}" connected.` });
|
||||||
|
} else {
|
||||||
|
setStatus({
|
||||||
|
type: 'error',
|
||||||
|
message: response?.error || `MCP client "${clientInfo}" failed to connect.`
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
setStatus({
|
||||||
|
type: 'error',
|
||||||
|
message: `MCP client "${clientInfo}" failed to connect: ${e}`
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [clientInfo, mcpRelayUrl]);
|
||||||
|
|
||||||
|
const handleReject = useCallback(() => {
|
||||||
|
setShowButtons(false);
|
||||||
|
setShowTabList(false);
|
||||||
|
setStatus({ type: 'error', message: 'Connection rejected. This tab can be closed.' });
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const listener = (message: any) => {
|
||||||
|
if (message.type === 'connectionTimeout')
|
||||||
|
handleReject();
|
||||||
|
};
|
||||||
|
chrome.runtime.onMessage.addListener(listener);
|
||||||
|
return () => {
|
||||||
|
chrome.runtime.onMessage.removeListener(listener);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className='app-container'>
|
||||||
|
<div className='content-wrapper'>
|
||||||
|
{status && (
|
||||||
|
<div className='status-container'>
|
||||||
|
<StatusBanner type={status.type} message={status.message} />
|
||||||
|
{showButtons && (
|
||||||
|
<Button variant='reject' onClick={handleReject}>
|
||||||
|
Reject
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{showTabList && (
|
||||||
|
<div>
|
||||||
|
<div className='tab-section-title'>
|
||||||
|
Select page to expose to MCP server:
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
{tabs.map(tab => (
|
||||||
|
<TabItem
|
||||||
|
key={tab.id}
|
||||||
|
tab={tab}
|
||||||
|
button={
|
||||||
|
<Button variant='primary' onClick={() => handleConnectToTab(tab)}>
|
||||||
|
Connect
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const StatusBanner: React.FC<{ type: StatusType; message: string }> = ({ type, message }) => {
|
||||||
|
return <div className={`status-banner ${type}`}>{message}</div>;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Initialize the React app
|
||||||
|
const container = document.getElementById('root');
|
||||||
|
if (container) {
|
||||||
|
const root = createRoot(container);
|
||||||
|
root.render(<ConnectApp />);
|
||||||
|
}
|
||||||
13
extension/src/ui/status.html
Normal file
13
extension/src/ui/status.html
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Playwright MCP Bridge Status</title>
|
||||||
|
<link rel="stylesheet" href="connect.css">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script src="status.tsx" type="module"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
110
extension/src/ui/status.tsx
Normal file
110
extension/src/ui/status.tsx
Normal file
@@ -0,0 +1,110 @@
|
|||||||
|
/**
|
||||||
|
* Copyright (c) Microsoft Corporation.
|
||||||
|
*
|
||||||
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
* you may not use this file except in compliance with the License.
|
||||||
|
* You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing, software
|
||||||
|
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
* See the License for the specific language governing permissions and
|
||||||
|
* limitations under the License.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React, { useState, useEffect } from 'react';
|
||||||
|
import { createRoot } from 'react-dom/client';
|
||||||
|
import { Button, TabItem } from './tabItem.js';
|
||||||
|
|
||||||
|
import type { TabInfo } from './tabItem.js';
|
||||||
|
|
||||||
|
interface ConnectionStatus {
|
||||||
|
isConnected: boolean;
|
||||||
|
connectedTabId: number | null;
|
||||||
|
connectedTab?: TabInfo;
|
||||||
|
}
|
||||||
|
|
||||||
|
const StatusApp: React.FC = () => {
|
||||||
|
const [status, setStatus] = useState<ConnectionStatus>({
|
||||||
|
isConnected: false,
|
||||||
|
connectedTabId: null
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void loadStatus();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const loadStatus = async () => {
|
||||||
|
// Get current connection status from background script
|
||||||
|
const { connectedTabId } = await chrome.runtime.sendMessage({ type: 'getConnectionStatus' });
|
||||||
|
if (connectedTabId) {
|
||||||
|
const tab = await chrome.tabs.get(connectedTabId);
|
||||||
|
setStatus({
|
||||||
|
isConnected: true,
|
||||||
|
connectedTabId,
|
||||||
|
connectedTab: {
|
||||||
|
id: tab.id!,
|
||||||
|
windowId: tab.windowId!,
|
||||||
|
title: tab.title!,
|
||||||
|
url: tab.url!,
|
||||||
|
favIconUrl: tab.favIconUrl
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
setStatus({
|
||||||
|
isConnected: false,
|
||||||
|
connectedTabId: null
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const openConnectedTab = async () => {
|
||||||
|
if (!status.connectedTabId)
|
||||||
|
return;
|
||||||
|
await chrome.tabs.update(status.connectedTabId, { active: true });
|
||||||
|
window.close();
|
||||||
|
};
|
||||||
|
|
||||||
|
const disconnect = async () => {
|
||||||
|
await chrome.runtime.sendMessage({ type: 'disconnect' });
|
||||||
|
window.close();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className='app-container'>
|
||||||
|
<div className='content-wrapper'>
|
||||||
|
{status.isConnected && status.connectedTab ? (
|
||||||
|
<div>
|
||||||
|
<div className='tab-section-title'>
|
||||||
|
Page with connected MCP client:
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<TabItem
|
||||||
|
tab={status.connectedTab}
|
||||||
|
button={
|
||||||
|
<Button variant='primary' onClick={disconnect}>
|
||||||
|
Disconnect
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
onClick={openConnectedTab}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className='status-banner'>
|
||||||
|
No MCP clients are currently connected.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Initialize the React app
|
||||||
|
const container = document.getElementById('root');
|
||||||
|
if (container) {
|
||||||
|
const root = createRoot(container);
|
||||||
|
root.render(<StatusApp />);
|
||||||
|
}
|
||||||
67
extension/src/ui/tabItem.tsx
Normal file
67
extension/src/ui/tabItem.tsx
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
/**
|
||||||
|
* Copyright (c) Microsoft Corporation.
|
||||||
|
*
|
||||||
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
* you may not use this file except in compliance with the License.
|
||||||
|
* You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing, software
|
||||||
|
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
* See the License for the specific language governing permissions and
|
||||||
|
* limitations under the License.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
export interface TabInfo {
|
||||||
|
id: number;
|
||||||
|
windowId: number;
|
||||||
|
title: string;
|
||||||
|
url: string;
|
||||||
|
favIconUrl?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const Button: React.FC<{ variant: 'primary' | 'default' | 'reject'; onClick: () => void; children: React.ReactNode }> = ({
|
||||||
|
variant,
|
||||||
|
onClick,
|
||||||
|
children
|
||||||
|
}) => {
|
||||||
|
return (
|
||||||
|
<button className={`button ${variant}`} onClick={onClick}>
|
||||||
|
{children}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
export interface TabItemProps {
|
||||||
|
tab: TabInfo;
|
||||||
|
onClick?: () => void;
|
||||||
|
button?: React.ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const TabItem: React.FC<TabItemProps> = ({
|
||||||
|
tab,
|
||||||
|
onClick,
|
||||||
|
button
|
||||||
|
}) => {
|
||||||
|
return (
|
||||||
|
<div className='tab-item' onClick={onClick} style={onClick ? { cursor: 'pointer' } : undefined}>
|
||||||
|
<img
|
||||||
|
src={tab.favIconUrl || 'data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16"><rect width="16" height="16" fill="%23f6f8fa"/></svg>'}
|
||||||
|
alt=''
|
||||||
|
className='tab-favicon'
|
||||||
|
/>
|
||||||
|
<div className='tab-content'>
|
||||||
|
<div className='tab-title'>
|
||||||
|
{tab.title || 'Untitled'}
|
||||||
|
</div>
|
||||||
|
<div className='tab-url'>{tab.url}</div>
|
||||||
|
</div>
|
||||||
|
{button}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
4
extension/src/ui/tsconfig.json
Normal file
4
extension/src/ui/tsconfig.json
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
// Help VSCode to find right tsconfig file.
|
||||||
|
{
|
||||||
|
"extends": "../../tsconfig.ui.json"
|
||||||
|
}
|
||||||
152
extension/tests/extension.spec.ts
Normal file
152
extension/tests/extension.spec.ts
Normal file
@@ -0,0 +1,152 @@
|
|||||||
|
/**
|
||||||
|
* Copyright (c) Microsoft Corporation.
|
||||||
|
*
|
||||||
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
* you may not use this file except in compliance with the License.
|
||||||
|
* You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing, software
|
||||||
|
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
* See the License for the specific language governing permissions and
|
||||||
|
* limitations under the License.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { fileURLToPath } from 'url';
|
||||||
|
import { chromium } from 'playwright';
|
||||||
|
import { test as base, expect } from '../../tests/fixtures.js';
|
||||||
|
|
||||||
|
import type { BrowserContext } from 'playwright';
|
||||||
|
|
||||||
|
type BrowserWithExtension = {
|
||||||
|
userDataDir: string;
|
||||||
|
launch: () => Promise<BrowserContext>;
|
||||||
|
};
|
||||||
|
|
||||||
|
const test = base.extend<{ browserWithExtension: BrowserWithExtension }>({
|
||||||
|
browserWithExtension: async ({ mcpBrowser }, use, testInfo) => {
|
||||||
|
// The flags no longer work in Chrome since
|
||||||
|
// https://chromium.googlesource.com/chromium/src/+/290ed8046692651ce76088914750cb659b65fb17%5E%21/chrome/browser/extensions/extension_service.cc?pli=1#
|
||||||
|
test.skip('chromium' !== mcpBrowser, '--load-extension is not supported for official builds of Chromium');
|
||||||
|
|
||||||
|
const pathToExtension = fileURLToPath(new URL('../dist', import.meta.url));
|
||||||
|
|
||||||
|
let browserContext: BrowserContext | undefined;
|
||||||
|
const userDataDir = testInfo.outputPath('extension-user-data-dir');
|
||||||
|
await use({
|
||||||
|
userDataDir,
|
||||||
|
launch: async () => {
|
||||||
|
browserContext = await chromium.launchPersistentContext(userDataDir, {
|
||||||
|
channel: mcpBrowser,
|
||||||
|
// Opening the browser singleton only works in headed.
|
||||||
|
headless: false,
|
||||||
|
// Automation disables singleton browser process behavior, which is necessary for the extension.
|
||||||
|
ignoreDefaultArgs: ['--enable-automation'],
|
||||||
|
args: [
|
||||||
|
`--disable-extensions-except=${pathToExtension}`,
|
||||||
|
`--load-extension=${pathToExtension}`,
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
// for manifest v3:
|
||||||
|
let [serviceWorker] = browserContext.serviceWorkers();
|
||||||
|
if (!serviceWorker)
|
||||||
|
serviceWorker = await browserContext.waitForEvent('serviceworker');
|
||||||
|
|
||||||
|
return browserContext;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
await browserContext?.close();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
test('navigate with extension', async ({ browserWithExtension, startClient, server }) => {
|
||||||
|
const browserContext = await browserWithExtension.launch();
|
||||||
|
|
||||||
|
const { client } = await startClient({
|
||||||
|
args: [`--connect-tool`],
|
||||||
|
config: {
|
||||||
|
browser: {
|
||||||
|
userDataDir: browserWithExtension.userDataDir,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(await client.callTool({
|
||||||
|
name: 'browser_connect',
|
||||||
|
arguments: {
|
||||||
|
method: 'extension'
|
||||||
|
}
|
||||||
|
})).toHaveResponse({
|
||||||
|
result: 'Successfully changed connection method.',
|
||||||
|
});
|
||||||
|
|
||||||
|
const confirmationPagePromise = browserContext.waitForEvent('page', page => {
|
||||||
|
return page.url().startsWith('chrome-extension://jakfalbnbhgkpmoaakfflhflbfpkailf/connect.html');
|
||||||
|
});
|
||||||
|
|
||||||
|
const navigateResponse = client.callTool({
|
||||||
|
name: 'browser_navigate',
|
||||||
|
arguments: { url: server.HELLO_WORLD },
|
||||||
|
});
|
||||||
|
|
||||||
|
const selectorPage = await confirmationPagePromise;
|
||||||
|
await selectorPage.locator('.tab-item', { hasText: 'Playwright MCP Extension' }).getByRole('button', { name: 'Connect' }).click();
|
||||||
|
|
||||||
|
expect(await navigateResponse).toHaveResponse({
|
||||||
|
pageState: expect.stringContaining(`- generic [active] [ref=e1]: Hello, world!`),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('snapshot of an existing page', async ({ browserWithExtension, startClient, server }) => {
|
||||||
|
const browserContext = await browserWithExtension.launch();
|
||||||
|
|
||||||
|
const page = await browserContext.newPage();
|
||||||
|
await page.goto(server.HELLO_WORLD);
|
||||||
|
|
||||||
|
// Another empty page.
|
||||||
|
await browserContext.newPage();
|
||||||
|
expect(browserContext.pages()).toHaveLength(3);
|
||||||
|
|
||||||
|
const { client } = await startClient({
|
||||||
|
args: [`--connect-tool`],
|
||||||
|
config: {
|
||||||
|
browser: {
|
||||||
|
userDataDir: browserWithExtension.userDataDir,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(await client.callTool({
|
||||||
|
name: 'browser_connect',
|
||||||
|
arguments: {
|
||||||
|
method: 'extension'
|
||||||
|
}
|
||||||
|
})).toHaveResponse({
|
||||||
|
result: 'Successfully changed connection method.',
|
||||||
|
});
|
||||||
|
expect(browserContext.pages()).toHaveLength(3);
|
||||||
|
|
||||||
|
const confirmationPagePromise = browserContext.waitForEvent('page', page => {
|
||||||
|
return page.url().startsWith('chrome-extension://jakfalbnbhgkpmoaakfflhflbfpkailf/connect.html');
|
||||||
|
});
|
||||||
|
|
||||||
|
const navigateResponse = client.callTool({
|
||||||
|
name: 'browser_snapshot',
|
||||||
|
arguments: { },
|
||||||
|
});
|
||||||
|
|
||||||
|
const selectorPage = await confirmationPagePromise;
|
||||||
|
expect(browserContext.pages()).toHaveLength(4);
|
||||||
|
|
||||||
|
await selectorPage.locator('.tab-item', { hasText: 'Title' }).getByRole('button', { name: 'Connect' }).click();
|
||||||
|
|
||||||
|
expect(await navigateResponse).toHaveResponse({
|
||||||
|
pageState: expect.stringContaining(`- generic [active] [ref=e1]: Hello, world!`),
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(browserContext.pages()).toHaveLength(4);
|
||||||
|
});
|
||||||
21
extension/tsconfig.json
Normal file
21
extension/tsconfig.json
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ESNext",
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"moduleResolution": "node",
|
||||||
|
"strict": true,
|
||||||
|
"module": "ESNext",
|
||||||
|
"rootDir": "src",
|
||||||
|
"outDir": "./dist/lib",
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"types": ["chrome"],
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
"jsxImportSource": "react"
|
||||||
|
},
|
||||||
|
"include": [
|
||||||
|
"src",
|
||||||
|
],
|
||||||
|
"exclude": [
|
||||||
|
"src/ui",
|
||||||
|
]
|
||||||
|
}
|
||||||
19
extension/tsconfig.ui.json
Normal file
19
extension/tsconfig.ui.json
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ESNext",
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"moduleResolution": "node",
|
||||||
|
"strict": true,
|
||||||
|
"module": "ESNext",
|
||||||
|
"rootDir": "src",
|
||||||
|
"outDir": "./lib",
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"types": ["chrome"],
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
"jsxImportSource": "react",
|
||||||
|
"noEmit": true,
|
||||||
|
},
|
||||||
|
"include": [
|
||||||
|
"src/ui",
|
||||||
|
],
|
||||||
|
}
|
||||||
54
extension/vite.config.ts
Normal file
54
extension/vite.config.ts
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
/**
|
||||||
|
* Copyright (c) Microsoft Corporation.
|
||||||
|
*
|
||||||
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
* you may not use this file except in compliance with the License.
|
||||||
|
* You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing, software
|
||||||
|
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
* See the License for the specific language governing permissions and
|
||||||
|
* limitations under the License.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { resolve } from 'path';
|
||||||
|
import { defineConfig } from 'vite';
|
||||||
|
import react from '@vitejs/plugin-react';
|
||||||
|
import { viteStaticCopy } from 'vite-plugin-static-copy';
|
||||||
|
|
||||||
|
// https://vitejs.dev/config/
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [
|
||||||
|
react(),
|
||||||
|
viteStaticCopy({
|
||||||
|
targets: [
|
||||||
|
{
|
||||||
|
src: '../../icons/*',
|
||||||
|
dest: 'icons'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
src: '../../manifest.json',
|
||||||
|
dest: '.'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
})
|
||||||
|
],
|
||||||
|
root: resolve(__dirname, 'src/ui'),
|
||||||
|
build: {
|
||||||
|
outDir: resolve(__dirname, 'dist/'),
|
||||||
|
emptyOutDir: false,
|
||||||
|
minify: false,
|
||||||
|
rollupOptions: {
|
||||||
|
input: ['src/ui/connect.html', 'src/ui/status.html'],
|
||||||
|
output: {
|
||||||
|
manualChunks: undefined,
|
||||||
|
entryFileNames: 'lib/ui/[name].js',
|
||||||
|
chunkFileNames: 'lib/ui/[name].js',
|
||||||
|
assetFileNames: 'lib/ui/[name].[ext]'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
7
index.d.ts
vendored
7
index.d.ts
vendored
@@ -19,10 +19,5 @@ import type { Server } from '@modelcontextprotocol/sdk/server/index.js';
|
|||||||
import type { Config } from './config.js';
|
import type { Config } from './config.js';
|
||||||
import type { BrowserContext } from 'playwright';
|
import type { BrowserContext } from 'playwright';
|
||||||
|
|
||||||
export type Connection = {
|
export declare function createConnection(config?: Config, contextGetter?: () => Promise<BrowserContext>): Promise<Server>;
|
||||||
server: Server;
|
|
||||||
close(): Promise<void>;
|
|
||||||
};
|
|
||||||
|
|
||||||
export declare function createConnection(config?: Config, contextGetter?: () => Promise<BrowserContext>): Promise<Connection>;
|
|
||||||
export {};
|
export {};
|
||||||
|
|||||||
682
package-lock.json
generated
682
package-lock.json
generated
@@ -1,43 +1,452 @@
|
|||||||
{
|
{
|
||||||
"name": "@playwright/mcp",
|
"name": "@playwright/mcp",
|
||||||
"version": "0.0.29",
|
"version": "0.0.33",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "@playwright/mcp",
|
"name": "@playwright/mcp",
|
||||||
"version": "0.0.29",
|
"version": "0.0.33",
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@modelcontextprotocol/sdk": "^1.11.0",
|
"@modelcontextprotocol/sdk": "^1.16.0",
|
||||||
"commander": "^13.1.0",
|
"commander": "^13.1.0",
|
||||||
"debug": "^4.4.1",
|
"debug": "^4.4.1",
|
||||||
|
"dotenv": "^17.2.0",
|
||||||
"mime": "^4.0.7",
|
"mime": "^4.0.7",
|
||||||
"playwright": "1.53.0",
|
"playwright": "1.55.0-alpha-2025-08-07",
|
||||||
|
"playwright-core": "1.55.0-alpha-2025-08-07",
|
||||||
|
"ws": "^8.18.1",
|
||||||
|
"zod": "^3.24.1",
|
||||||
"zod-to-json-schema": "^3.24.4"
|
"zod-to-json-schema": "^3.24.4"
|
||||||
},
|
},
|
||||||
"bin": {
|
"bin": {
|
||||||
"mcp-server-playwright": "cli.js"
|
"mcp-server-playwright": "cli.js"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@anthropic-ai/sdk": "^0.57.0",
|
||||||
"@eslint/eslintrc": "^3.2.0",
|
"@eslint/eslintrc": "^3.2.0",
|
||||||
"@eslint/js": "^9.19.0",
|
"@eslint/js": "^9.19.0",
|
||||||
"@playwright/test": "1.53.0",
|
"@playwright/test": "1.55.0-alpha-2025-08-07",
|
||||||
"@stylistic/eslint-plugin": "^3.0.1",
|
"@stylistic/eslint-plugin": "^3.0.1",
|
||||||
"@types/debug": "^4.1.12",
|
"@types/debug": "^4.1.12",
|
||||||
"@types/node": "^22.13.10",
|
"@types/node": "^22.13.10",
|
||||||
|
"@types/ws": "^8.18.1",
|
||||||
"@typescript-eslint/eslint-plugin": "^8.26.1",
|
"@typescript-eslint/eslint-plugin": "^8.26.1",
|
||||||
"@typescript-eslint/parser": "^8.26.1",
|
"@typescript-eslint/parser": "^8.26.1",
|
||||||
"@typescript-eslint/utils": "^8.26.1",
|
"@typescript-eslint/utils": "^8.26.1",
|
||||||
|
"esbuild": "^0.20.1",
|
||||||
"eslint": "^9.19.0",
|
"eslint": "^9.19.0",
|
||||||
"eslint-plugin-import": "^2.31.0",
|
"eslint-plugin-import": "^2.31.0",
|
||||||
"eslint-plugin-notice": "^1.0.0",
|
"eslint-plugin-notice": "^1.0.0",
|
||||||
|
"openai": "^5.10.2",
|
||||||
"typescript": "^5.8.2"
|
"typescript": "^5.8.2"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=18"
|
"node": ">=18"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@anthropic-ai/sdk": {
|
||||||
|
"version": "0.57.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@anthropic-ai/sdk/-/sdk-0.57.0.tgz",
|
||||||
|
"integrity": "sha512-z5LMy0MWu0+w2hflUgj4RlJr1R+0BxKXL7ldXTO8FasU8fu599STghO+QKwId2dAD0d464aHtU+ChWuRHw4FNw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"bin": {
|
||||||
|
"anthropic-ai-sdk": "bin/cli"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/aix-ppc64": {
|
||||||
|
"version": "0.20.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.20.2.tgz",
|
||||||
|
"integrity": "sha512-D+EBOJHXdNZcLJRBkhENNG8Wji2kgc9AZ9KiPr1JuZjsNtyHzrsfLRrY0tk2H2aoFu6RANO1y1iPPUCDYWkb5g==",
|
||||||
|
"cpu": [
|
||||||
|
"ppc64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"aix"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/android-arm": {
|
||||||
|
"version": "0.20.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.20.2.tgz",
|
||||||
|
"integrity": "sha512-t98Ra6pw2VaDhqNWO2Oph2LXbz/EJcnLmKLGBJwEwXX/JAN83Fym1rU8l0JUWK6HkIbWONCSSatf4sf2NBRx/w==",
|
||||||
|
"cpu": [
|
||||||
|
"arm"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"android"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/android-arm64": {
|
||||||
|
"version": "0.20.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.20.2.tgz",
|
||||||
|
"integrity": "sha512-mRzjLacRtl/tWU0SvD8lUEwb61yP9cqQo6noDZP/O8VkwafSYwZ4yWy24kan8jE/IMERpYncRt2dw438LP3Xmg==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"android"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/android-x64": {
|
||||||
|
"version": "0.20.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.20.2.tgz",
|
||||||
|
"integrity": "sha512-btzExgV+/lMGDDa194CcUQm53ncxzeBrWJcncOBxuC6ndBkKxnHdFJn86mCIgTELsooUmwUm9FkhSp5HYu00Rg==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"android"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/darwin-arm64": {
|
||||||
|
"version": "0.20.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.20.2.tgz",
|
||||||
|
"integrity": "sha512-4J6IRT+10J3aJH3l1yzEg9y3wkTDgDk7TSDFX+wKFiWjqWp/iCfLIYzGyasx9l0SAFPT1HwSCR+0w/h1ES/MjA==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"darwin"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/darwin-x64": {
|
||||||
|
"version": "0.20.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.20.2.tgz",
|
||||||
|
"integrity": "sha512-tBcXp9KNphnNH0dfhv8KYkZhjc+H3XBkF5DKtswJblV7KlT9EI2+jeA8DgBjp908WEuYll6pF+UStUCfEpdysA==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"darwin"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/freebsd-arm64": {
|
||||||
|
"version": "0.20.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.20.2.tgz",
|
||||||
|
"integrity": "sha512-d3qI41G4SuLiCGCFGUrKsSeTXyWG6yem1KcGZVS+3FYlYhtNoNgYrWcvkOoaqMhwXSMrZRl69ArHsGJ9mYdbbw==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"freebsd"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/freebsd-x64": {
|
||||||
|
"version": "0.20.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.20.2.tgz",
|
||||||
|
"integrity": "sha512-d+DipyvHRuqEeM5zDivKV1KuXn9WeRX6vqSqIDgwIfPQtwMP4jaDsQsDncjTDDsExT4lR/91OLjRo8bmC1e+Cw==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"freebsd"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/linux-arm": {
|
||||||
|
"version": "0.20.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.20.2.tgz",
|
||||||
|
"integrity": "sha512-VhLPeR8HTMPccbuWWcEUD1Az68TqaTYyj6nfE4QByZIQEQVWBB8vup8PpR7y1QHL3CpcF6xd5WVBU/+SBEvGTg==",
|
||||||
|
"cpu": [
|
||||||
|
"arm"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/linux-arm64": {
|
||||||
|
"version": "0.20.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.20.2.tgz",
|
||||||
|
"integrity": "sha512-9pb6rBjGvTFNira2FLIWqDk/uaf42sSyLE8j1rnUpuzsODBq7FvpwHYZxQ/It/8b+QOS1RYfqgGFNLRI+qlq2A==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/linux-ia32": {
|
||||||
|
"version": "0.20.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.20.2.tgz",
|
||||||
|
"integrity": "sha512-o10utieEkNPFDZFQm9CoP7Tvb33UutoJqg3qKf1PWVeeJhJw0Q347PxMvBgVVFgouYLGIhFYG0UGdBumROyiig==",
|
||||||
|
"cpu": [
|
||||||
|
"ia32"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/linux-loong64": {
|
||||||
|
"version": "0.20.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.20.2.tgz",
|
||||||
|
"integrity": "sha512-PR7sp6R/UC4CFVomVINKJ80pMFlfDfMQMYynX7t1tNTeivQ6XdX5r2XovMmha/VjR1YN/HgHWsVcTRIMkymrgQ==",
|
||||||
|
"cpu": [
|
||||||
|
"loong64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/linux-mips64el": {
|
||||||
|
"version": "0.20.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.20.2.tgz",
|
||||||
|
"integrity": "sha512-4BlTqeutE/KnOiTG5Y6Sb/Hw6hsBOZapOVF6njAESHInhlQAghVVZL1ZpIctBOoTFbQyGW+LsVYZ8lSSB3wkjA==",
|
||||||
|
"cpu": [
|
||||||
|
"mips64el"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/linux-ppc64": {
|
||||||
|
"version": "0.20.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.20.2.tgz",
|
||||||
|
"integrity": "sha512-rD3KsaDprDcfajSKdn25ooz5J5/fWBylaaXkuotBDGnMnDP1Uv5DLAN/45qfnf3JDYyJv/ytGHQaziHUdyzaAg==",
|
||||||
|
"cpu": [
|
||||||
|
"ppc64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/linux-riscv64": {
|
||||||
|
"version": "0.20.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.20.2.tgz",
|
||||||
|
"integrity": "sha512-snwmBKacKmwTMmhLlz/3aH1Q9T8v45bKYGE3j26TsaOVtjIag4wLfWSiZykXzXuE1kbCE+zJRmwp+ZbIHinnVg==",
|
||||||
|
"cpu": [
|
||||||
|
"riscv64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/linux-s390x": {
|
||||||
|
"version": "0.20.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.20.2.tgz",
|
||||||
|
"integrity": "sha512-wcWISOobRWNm3cezm5HOZcYz1sKoHLd8VL1dl309DiixxVFoFe/o8HnwuIwn6sXre88Nwj+VwZUvJf4AFxkyrQ==",
|
||||||
|
"cpu": [
|
||||||
|
"s390x"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/linux-x64": {
|
||||||
|
"version": "0.20.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.20.2.tgz",
|
||||||
|
"integrity": "sha512-1MdwI6OOTsfQfek8sLwgyjOXAu+wKhLEoaOLTjbijk6E2WONYpH9ZU2mNtR+lZ2B4uwr+usqGuVfFT9tMtGvGw==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/netbsd-x64": {
|
||||||
|
"version": "0.20.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.20.2.tgz",
|
||||||
|
"integrity": "sha512-K8/DhBxcVQkzYc43yJXDSyjlFeHQJBiowJ0uVL6Tor3jGQfSGHNNJcWxNbOI8v5k82prYqzPuwkzHt3J1T1iZQ==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"netbsd"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/openbsd-x64": {
|
||||||
|
"version": "0.20.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.20.2.tgz",
|
||||||
|
"integrity": "sha512-eMpKlV0SThJmmJgiVyN9jTPJ2VBPquf6Kt/nAoo6DgHAoN57K15ZghiHaMvqjCye/uU4X5u3YSMgVBI1h3vKrQ==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"openbsd"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/sunos-x64": {
|
||||||
|
"version": "0.20.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.20.2.tgz",
|
||||||
|
"integrity": "sha512-2UyFtRC6cXLyejf/YEld4Hajo7UHILetzE1vsRcGL3earZEW77JxrFjH4Ez2qaTiEfMgAXxfAZCm1fvM/G/o8w==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"sunos"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/win32-arm64": {
|
||||||
|
"version": "0.20.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.20.2.tgz",
|
||||||
|
"integrity": "sha512-GRibxoawM9ZCnDxnP3usoUDO9vUkpAxIIZ6GQI+IlVmr5kP3zUq+l17xELTHMWTWzjxa2guPNyrpq1GWmPvcGQ==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"win32"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/win32-ia32": {
|
||||||
|
"version": "0.20.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.20.2.tgz",
|
||||||
|
"integrity": "sha512-HfLOfn9YWmkSKRQqovpnITazdtquEW8/SoHW7pWpuEeguaZI4QnCRW6b+oZTztdBnZOS2hqJ6im/D5cPzBTTlQ==",
|
||||||
|
"cpu": [
|
||||||
|
"ia32"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"win32"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/win32-x64": {
|
||||||
|
"version": "0.20.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.20.2.tgz",
|
||||||
|
"integrity": "sha512-N49X4lJX27+l9jbLKSqZ6bKNjzQvHaT8IIFUy+YIqmXQdjYCToGWwOItDrfby14c78aDd5NHQl29xingXfCdLQ==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"win32"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@eslint-community/eslint-utils": {
|
"node_modules/@eslint-community/eslint-utils": {
|
||||||
"version": "4.5.1",
|
"version": "4.5.1",
|
||||||
"resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.5.1.tgz",
|
"resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.5.1.tgz",
|
||||||
@@ -68,9 +477,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@eslint/config-array": {
|
"node_modules/@eslint/config-array": {
|
||||||
"version": "0.19.2",
|
"version": "0.21.0",
|
||||||
"resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.19.2.tgz",
|
"resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.21.0.tgz",
|
||||||
"integrity": "sha512-GNKqxfHG2ySmJOBSHg7LxeUx4xpuCoFjacmlCoYWEbaPXLwvfIjixRI12xCQZeULksQb23uiA8F40w5TojpV7w==",
|
"integrity": "sha512-ENIdc4iLu0d93HeYirvKmrzshzofPw6VkZRKQGe9Nv46ZnWUzcF1xV01dcvEg/1wXUR61OmmlSfyeyO7EvjLxQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
@@ -83,9 +492,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@eslint/config-helpers": {
|
"node_modules/@eslint/config-helpers": {
|
||||||
"version": "0.1.0",
|
"version": "0.3.0",
|
||||||
"resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.3.0.tgz",
|
||||||
"integrity": "sha512-kLrdPDJE1ckPo94kmPPf9Hfd0DU0Jw6oKYrhe+pwSC0iTUInmTa+w6fw8sGgcfkFJGNdWOUeOaDM4quW4a7OkA==",
|
"integrity": "sha512-ViuymvFmcJi04qdZeDc2whTHryouGcDlaxPqarTD0ZE10ISpxGUVZGZDx4w01upyIynL3iu6IXH2bS1NhclQMw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"engines": {
|
"engines": {
|
||||||
@@ -93,9 +502,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@eslint/core": {
|
"node_modules/@eslint/core": {
|
||||||
"version": "0.12.0",
|
"version": "0.15.1",
|
||||||
"resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.12.0.tgz",
|
"resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.15.1.tgz",
|
||||||
"integrity": "sha512-cmrR6pytBuSMTaBweKoGMwu3EiHiEC+DoyupPmlZ0HxBJBtIxwe+j/E4XPIKNx+Q74c8lXKPwYawBf5glsTkHg==",
|
"integrity": "sha512-bkOp+iumZCCbt1K1CmWf0R9pM5yKpDv+ZXtvSyQpudrI9kuFLp+bM2WOPXImuD/ceQuaa8f5pj93Y7zyECIGNA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
@@ -106,9 +515,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@eslint/eslintrc": {
|
"node_modules/@eslint/eslintrc": {
|
||||||
"version": "3.3.0",
|
"version": "3.3.1",
|
||||||
"resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.3.0.tgz",
|
"resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.3.1.tgz",
|
||||||
"integrity": "sha512-yaVPAiNAalnCZedKLdR21GOGILMLKPyqSLWaAjQFvYA2i/ciDi8ArYVr69Anohb6cH2Ukhqti4aFnYyPm8wdwQ==",
|
"integrity": "sha512-gtF186CXhIl1p4pJNGZw8Yc6RlshoePRvE0X91oPGb3vZ8pM3qOS9W9NGPat9LziaBV7XrJWGylNQXkGcnM3IQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
@@ -130,13 +539,16 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@eslint/js": {
|
"node_modules/@eslint/js": {
|
||||||
"version": "9.22.0",
|
"version": "9.31.0",
|
||||||
"resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.22.0.tgz",
|
"resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.31.0.tgz",
|
||||||
"integrity": "sha512-vLFajx9o8d1/oL2ZkpMYbkLv8nDB6yaIwFNt7nI4+I80U/z03SxmfOMsLbvWr3p7C+Wnoh//aOu2pQW8cS0HCQ==",
|
"integrity": "sha512-LOm5OVt7D4qiKCqoiPbA7LWmI+tbw1VbTUowBcUMgQSuM6poJufkFkYDcQpo5KfgD39TnNySV26QjOh7VFpSyw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
|
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://eslint.org/donate"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@eslint/object-schema": {
|
"node_modules/@eslint/object-schema": {
|
||||||
@@ -150,13 +562,13 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@eslint/plugin-kit": {
|
"node_modules/@eslint/plugin-kit": {
|
||||||
"version": "0.2.7",
|
"version": "0.3.4",
|
||||||
"resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.2.7.tgz",
|
"resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.3.4.tgz",
|
||||||
"integrity": "sha512-JubJ5B2pJ4k4yGxaNLdbjrnk9d/iDz6/q8wOilpIowd6PJPgaxCuHBnBszq7Ce2TyMrywm5r4PnKm6V3iiZF+g==",
|
"integrity": "sha512-Ul5l+lHEcw3L5+k8POx6r74mxEYKG5kOb6Xpy2gCRW6zweT6TEhAf8vhxGgjhqrd/VO/Dirhsb+1hNpD1ue9hw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@eslint/core": "^0.12.0",
|
"@eslint/core": "^0.15.1",
|
||||||
"levn": "^0.4.1"
|
"levn": "^0.4.1"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
@@ -230,15 +642,17 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@modelcontextprotocol/sdk": {
|
"node_modules/@modelcontextprotocol/sdk": {
|
||||||
"version": "1.11.0",
|
"version": "1.16.0",
|
||||||
"resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.11.0.tgz",
|
"resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.16.0.tgz",
|
||||||
"integrity": "sha512-k/1pb70eD638anoi0e8wUGAlbMJXyvdV4p62Ko+EZ7eBe1xMx8Uhak1R5DgfoofsK5IBBnRwsYGTaLZl+6/+RQ==",
|
"integrity": "sha512-8ofX7gkZcLj9H9rSd50mCgm3SSF8C7XoclxJuLoV0Cz3rEQ1tv9MZRYYvJtm9n1BiEQQMzSmE/w2AEkNacLYfg==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"ajv": "^6.12.6",
|
||||||
"content-type": "^1.0.5",
|
"content-type": "^1.0.5",
|
||||||
"cors": "^2.8.5",
|
"cors": "^2.8.5",
|
||||||
"cross-spawn": "^7.0.3",
|
"cross-spawn": "^7.0.5",
|
||||||
"eventsource": "^3.0.2",
|
"eventsource": "^3.0.2",
|
||||||
|
"eventsource-parser": "^3.0.0",
|
||||||
"express": "^5.0.1",
|
"express": "^5.0.1",
|
||||||
"express-rate-limit": "^7.5.0",
|
"express-rate-limit": "^7.5.0",
|
||||||
"pkce-challenge": "^5.0.0",
|
"pkce-challenge": "^5.0.0",
|
||||||
@@ -289,12 +703,13 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@playwright/test": {
|
"node_modules/@playwright/test": {
|
||||||
"version": "1.53.0",
|
"version": "1.55.0-alpha-2025-08-07",
|
||||||
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.53.0.tgz",
|
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.55.0-alpha-2025-08-07.tgz",
|
||||||
"integrity": "sha512-15hjKreZDcp7t6TL/7jkAo6Df5STZN09jGiv5dbP9A6vMVncXRqE7/B2SncsyOwrkZRBH2i6/TPOL8BVmm3c7w==",
|
"integrity": "sha512-N83L8JSSJ+E690HCbgzmXIcbRfM/rlh0uWZhbHbMp9q4qDPABSgvhm0HGiG345PV1ozoqcCI/mXLZPircsmPIA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"playwright": "1.53.0"
|
"playwright": "1.55.0-alpha-2025-08-07"
|
||||||
},
|
},
|
||||||
"bin": {
|
"bin": {
|
||||||
"playwright": "cli.js"
|
"playwright": "cli.js"
|
||||||
@@ -367,9 +782,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@types/estree": {
|
"node_modules/@types/estree": {
|
||||||
"version": "1.0.6",
|
"version": "1.0.8",
|
||||||
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.6.tgz",
|
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
|
||||||
"integrity": "sha512-AYnb1nQyY49te+VRAVgmzfcgjYS91mY5P0TKUDCLEM+gNnA+3T6rWITXRLYCpahpqSQbN5cE+gHpnPyXjHWxcw==",
|
"integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
@@ -404,6 +819,16 @@
|
|||||||
"undici-types": "~6.20.0"
|
"undici-types": "~6.20.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/ws": {
|
||||||
|
"version": "8.18.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz",
|
||||||
|
"integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/node": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@typescript-eslint/eslint-plugin": {
|
"node_modules/@typescript-eslint/eslint-plugin": {
|
||||||
"version": "8.27.0",
|
"version": "8.27.0",
|
||||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.27.0.tgz",
|
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.27.0.tgz",
|
||||||
@@ -543,9 +968,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": {
|
"node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": {
|
||||||
"version": "2.0.1",
|
"version": "2.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz",
|
||||||
"integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==",
|
"integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
@@ -637,9 +1062,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/acorn": {
|
"node_modules/acorn": {
|
||||||
"version": "8.14.1",
|
"version": "8.15.0",
|
||||||
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.14.1.tgz",
|
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz",
|
||||||
"integrity": "sha512-OvQ/2pUDKmgfCg++xsTX1wGxfTaszcHVcTctW4UJB4hibJx2HXxxO5UmVgyjMa+ZDsiaf5wWLXYpRWMmBI0QHg==",
|
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"bin": {
|
"bin": {
|
||||||
@@ -663,7 +1088,6 @@
|
|||||||
"version": "6.12.6",
|
"version": "6.12.6",
|
||||||
"resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz",
|
"resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz",
|
||||||
"integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==",
|
"integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"fast-deep-equal": "^3.1.1",
|
"fast-deep-equal": "^3.1.1",
|
||||||
@@ -873,9 +1297,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/brace-expansion": {
|
"node_modules/brace-expansion": {
|
||||||
"version": "1.1.11",
|
"version": "1.1.12",
|
||||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz",
|
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz",
|
||||||
"integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==",
|
"integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
@@ -1218,6 +1642,18 @@
|
|||||||
"node": ">=0.10.0"
|
"node": ">=0.10.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/dotenv": {
|
||||||
|
"version": "17.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.2.0.tgz",
|
||||||
|
"integrity": "sha512-Q4sgBT60gzd0BB0lSyYD3xM4YxrXA9y4uBDof1JNYGzOXrQdQ6yX+7XIAqoFOGQFOTK1D3Hts5OllpxMDZFONQ==",
|
||||||
|
"license": "BSD-2-Clause",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://dotenvx.com"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/dunder-proto": {
|
"node_modules/dunder-proto": {
|
||||||
"version": "1.0.1",
|
"version": "1.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
|
||||||
@@ -1390,6 +1826,45 @@
|
|||||||
"url": "https://github.com/sponsors/ljharb"
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/esbuild": {
|
||||||
|
"version": "0.20.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.20.2.tgz",
|
||||||
|
"integrity": "sha512-WdOOppmUNU+IbZ0PaDiTst80zjnrOkyJNHoKupIcVyU8Lvla3Ugx94VzkQ32Ijqd7UhHJy75gNWDMUekcrSJ6g==",
|
||||||
|
"dev": true,
|
||||||
|
"hasInstallScript": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"bin": {
|
||||||
|
"esbuild": "bin/esbuild"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
},
|
||||||
|
"optionalDependencies": {
|
||||||
|
"@esbuild/aix-ppc64": "0.20.2",
|
||||||
|
"@esbuild/android-arm": "0.20.2",
|
||||||
|
"@esbuild/android-arm64": "0.20.2",
|
||||||
|
"@esbuild/android-x64": "0.20.2",
|
||||||
|
"@esbuild/darwin-arm64": "0.20.2",
|
||||||
|
"@esbuild/darwin-x64": "0.20.2",
|
||||||
|
"@esbuild/freebsd-arm64": "0.20.2",
|
||||||
|
"@esbuild/freebsd-x64": "0.20.2",
|
||||||
|
"@esbuild/linux-arm": "0.20.2",
|
||||||
|
"@esbuild/linux-arm64": "0.20.2",
|
||||||
|
"@esbuild/linux-ia32": "0.20.2",
|
||||||
|
"@esbuild/linux-loong64": "0.20.2",
|
||||||
|
"@esbuild/linux-mips64el": "0.20.2",
|
||||||
|
"@esbuild/linux-ppc64": "0.20.2",
|
||||||
|
"@esbuild/linux-riscv64": "0.20.2",
|
||||||
|
"@esbuild/linux-s390x": "0.20.2",
|
||||||
|
"@esbuild/linux-x64": "0.20.2",
|
||||||
|
"@esbuild/netbsd-x64": "0.20.2",
|
||||||
|
"@esbuild/openbsd-x64": "0.20.2",
|
||||||
|
"@esbuild/sunos-x64": "0.20.2",
|
||||||
|
"@esbuild/win32-arm64": "0.20.2",
|
||||||
|
"@esbuild/win32-ia32": "0.20.2",
|
||||||
|
"@esbuild/win32-x64": "0.20.2"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/escape-html": {
|
"node_modules/escape-html": {
|
||||||
"version": "1.0.3",
|
"version": "1.0.3",
|
||||||
"resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz",
|
"resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz",
|
||||||
@@ -1410,20 +1885,20 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/eslint": {
|
"node_modules/eslint": {
|
||||||
"version": "9.22.0",
|
"version": "9.31.0",
|
||||||
"resolved": "https://registry.npmjs.org/eslint/-/eslint-9.22.0.tgz",
|
"resolved": "https://registry.npmjs.org/eslint/-/eslint-9.31.0.tgz",
|
||||||
"integrity": "sha512-9V/QURhsRN40xuHXWjV64yvrzMjcz7ZyNoF2jJFmy9j/SLk0u1OLSZgXi28MrXjymnjEGSR80WCdab3RGMDveQ==",
|
"integrity": "sha512-QldCVh/ztyKJJZLr4jXNUByx3gR+TDYZCRXEktiZoUR3PGy4qCmSbkxcIle8GEwGpb5JBZazlaJ/CxLidXdEbQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@eslint-community/eslint-utils": "^4.2.0",
|
"@eslint-community/eslint-utils": "^4.2.0",
|
||||||
"@eslint-community/regexpp": "^4.12.1",
|
"@eslint-community/regexpp": "^4.12.1",
|
||||||
"@eslint/config-array": "^0.19.2",
|
"@eslint/config-array": "^0.21.0",
|
||||||
"@eslint/config-helpers": "^0.1.0",
|
"@eslint/config-helpers": "^0.3.0",
|
||||||
"@eslint/core": "^0.12.0",
|
"@eslint/core": "^0.15.0",
|
||||||
"@eslint/eslintrc": "^3.3.0",
|
"@eslint/eslintrc": "^3.3.1",
|
||||||
"@eslint/js": "9.22.0",
|
"@eslint/js": "9.31.0",
|
||||||
"@eslint/plugin-kit": "^0.2.7",
|
"@eslint/plugin-kit": "^0.3.1",
|
||||||
"@humanfs/node": "^0.16.6",
|
"@humanfs/node": "^0.16.6",
|
||||||
"@humanwhocodes/module-importer": "^1.0.1",
|
"@humanwhocodes/module-importer": "^1.0.1",
|
||||||
"@humanwhocodes/retry": "^0.4.2",
|
"@humanwhocodes/retry": "^0.4.2",
|
||||||
@@ -1434,9 +1909,9 @@
|
|||||||
"cross-spawn": "^7.0.6",
|
"cross-spawn": "^7.0.6",
|
||||||
"debug": "^4.3.2",
|
"debug": "^4.3.2",
|
||||||
"escape-string-regexp": "^4.0.0",
|
"escape-string-regexp": "^4.0.0",
|
||||||
"eslint-scope": "^8.3.0",
|
"eslint-scope": "^8.4.0",
|
||||||
"eslint-visitor-keys": "^4.2.0",
|
"eslint-visitor-keys": "^4.2.1",
|
||||||
"espree": "^10.3.0",
|
"espree": "^10.4.0",
|
||||||
"esquery": "^1.5.0",
|
"esquery": "^1.5.0",
|
||||||
"esutils": "^2.0.2",
|
"esutils": "^2.0.2",
|
||||||
"fast-deep-equal": "^3.1.3",
|
"fast-deep-equal": "^3.1.3",
|
||||||
@@ -1590,9 +2065,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/eslint-scope": {
|
"node_modules/eslint-scope": {
|
||||||
"version": "8.3.0",
|
"version": "8.4.0",
|
||||||
"resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-8.3.0.tgz",
|
"resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-8.4.0.tgz",
|
||||||
"integrity": "sha512-pUNxi75F8MJ/GdeKtVLSbYg4ZI34J6C0C7sbL4YOp2exGwen7ZsuBqKzUhXd0qMQ362yET3z+uPwKeg/0C2XCQ==",
|
"integrity": "sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "BSD-2-Clause",
|
"license": "BSD-2-Clause",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
@@ -1620,9 +2095,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/eslint/node_modules/eslint-visitor-keys": {
|
"node_modules/eslint/node_modules/eslint-visitor-keys": {
|
||||||
"version": "4.2.0",
|
"version": "4.2.1",
|
||||||
"resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz",
|
||||||
"integrity": "sha512-UyLnSehNt62FFhSwjZlHmeokpRK59rcz29j+F1/aDgbkbRTk7wIc9XzdoasMUbRNKDM0qQt/+BJ4BrpFeABemw==",
|
"integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"engines": {
|
"engines": {
|
||||||
@@ -1633,15 +2108,15 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/espree": {
|
"node_modules/espree": {
|
||||||
"version": "10.3.0",
|
"version": "10.4.0",
|
||||||
"resolved": "https://registry.npmjs.org/espree/-/espree-10.3.0.tgz",
|
"resolved": "https://registry.npmjs.org/espree/-/espree-10.4.0.tgz",
|
||||||
"integrity": "sha512-0QYC8b24HWY8zjRnDTL6RiHfDbAWn63qb4LMj1Z4b076A4une81+z03Kg7l7mn/48PUTqoLptSXez8oknU8Clg==",
|
"integrity": "sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "BSD-2-Clause",
|
"license": "BSD-2-Clause",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"acorn": "^8.14.0",
|
"acorn": "^8.15.0",
|
||||||
"acorn-jsx": "^5.3.2",
|
"acorn-jsx": "^5.3.2",
|
||||||
"eslint-visitor-keys": "^4.2.0"
|
"eslint-visitor-keys": "^4.2.1"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
|
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
|
||||||
@@ -1651,9 +2126,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/espree/node_modules/eslint-visitor-keys": {
|
"node_modules/espree/node_modules/eslint-visitor-keys": {
|
||||||
"version": "4.2.0",
|
"version": "4.2.1",
|
||||||
"resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz",
|
||||||
"integrity": "sha512-UyLnSehNt62FFhSwjZlHmeokpRK59rcz29j+F1/aDgbkbRTk7wIc9XzdoasMUbRNKDM0qQt/+BJ4BrpFeABemw==",
|
"integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"engines": {
|
"engines": {
|
||||||
@@ -1800,7 +2275,6 @@
|
|||||||
"version": "3.1.3",
|
"version": "3.1.3",
|
||||||
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
|
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
|
||||||
"integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==",
|
"integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/fast-glob": {
|
"node_modules/fast-glob": {
|
||||||
@@ -1837,7 +2311,6 @@
|
|||||||
"version": "2.1.0",
|
"version": "2.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz",
|
||||||
"integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==",
|
"integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/fast-levenshtein": {
|
"node_modules/fast-levenshtein": {
|
||||||
@@ -1984,6 +2457,7 @@
|
|||||||
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
|
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
|
||||||
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
|
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
|
||||||
"hasInstallScript": true,
|
"hasInstallScript": true,
|
||||||
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
"darwin"
|
"darwin"
|
||||||
@@ -2757,7 +3231,6 @@
|
|||||||
"version": "0.4.1",
|
"version": "0.4.1",
|
||||||
"resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz",
|
"resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz",
|
||||||
"integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==",
|
"integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/json-stable-stringify-without-jsonify": {
|
"node_modules/json-stable-stringify-without-jsonify": {
|
||||||
@@ -3102,6 +3575,28 @@
|
|||||||
"wrappy": "1"
|
"wrappy": "1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/openai": {
|
||||||
|
"version": "5.10.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/openai/-/openai-5.10.2.tgz",
|
||||||
|
"integrity": "sha512-n+vi74LzHtvlKcDPn9aApgELGiu5CwhaLG40zxLTlFQdoSJCLACORIPC2uVQ3JEYAbqapM+XyRKFy2Thej7bIw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"bin": {
|
||||||
|
"openai": "bin/cli"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"ws": "^8.18.0",
|
||||||
|
"zod": "^3.23.8"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"ws": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"zod": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/optionator": {
|
"node_modules/optionator": {
|
||||||
"version": "0.9.4",
|
"version": "0.9.4",
|
||||||
"resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz",
|
"resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz",
|
||||||
@@ -3250,11 +3745,12 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/playwright": {
|
"node_modules/playwright": {
|
||||||
"version": "1.53.0",
|
"version": "1.55.0-alpha-2025-08-07",
|
||||||
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.53.0.tgz",
|
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.55.0-alpha-2025-08-07.tgz",
|
||||||
"integrity": "sha512-ghGNnIEYZC4E+YtclRn4/p6oYbdPiASELBIYkBXfaTVKreQUYbMUYQDwS12a8F0/HtIjr/CkGjtwABeFPGcS4Q==",
|
"integrity": "sha512-rH8kdQOZzhjxC6FOL9zSEDwPl88ZqQq9QEvRDONWhzKwRQ/jOXlEZRxm8QRCBdrLqBMTGHx/YOaP7MIV//rtIA==",
|
||||||
|
"license": "Apache-2.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"playwright-core": "1.53.0"
|
"playwright-core": "1.55.0-alpha-2025-08-07"
|
||||||
},
|
},
|
||||||
"bin": {
|
"bin": {
|
||||||
"playwright": "cli.js"
|
"playwright": "cli.js"
|
||||||
@@ -3267,9 +3763,10 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/playwright-core": {
|
"node_modules/playwright-core": {
|
||||||
"version": "1.53.0",
|
"version": "1.55.0-alpha-2025-08-07",
|
||||||
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.53.0.tgz",
|
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.55.0-alpha-2025-08-07.tgz",
|
||||||
"integrity": "sha512-mGLg8m0pm4+mmtB7M89Xw/GSqoNC+twivl8ITteqvAndachozYe2ZA7srU6uleV1vEdAHYqjq+SV8SNxRRFYBw==",
|
"integrity": "sha512-NUuC6R0/dLk1QKiYoJL8NUsQAC6Je0C2BpuIg5h4wcvBwJ5TFldslmik17Txg3TXBSqwgG76DAl4Q6UdHGn54Q==",
|
||||||
|
"license": "Apache-2.0",
|
||||||
"bin": {
|
"bin": {
|
||||||
"playwright-core": "cli.js"
|
"playwright-core": "cli.js"
|
||||||
},
|
},
|
||||||
@@ -3314,7 +3811,6 @@
|
|||||||
"version": "2.3.1",
|
"version": "2.3.1",
|
||||||
"resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",
|
"resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",
|
||||||
"integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==",
|
"integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=6"
|
"node": ">=6"
|
||||||
@@ -4108,7 +4604,6 @@
|
|||||||
"version": "4.4.1",
|
"version": "4.4.1",
|
||||||
"resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz",
|
"resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz",
|
||||||
"integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==",
|
"integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==",
|
||||||
"dev": true,
|
|
||||||
"license": "BSD-2-Clause",
|
"license": "BSD-2-Clause",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"punycode": "^2.1.0"
|
"punycode": "^2.1.0"
|
||||||
@@ -4243,6 +4738,27 @@
|
|||||||
"integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==",
|
"integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==",
|
||||||
"license": "ISC"
|
"license": "ISC"
|
||||||
},
|
},
|
||||||
|
"node_modules/ws": {
|
||||||
|
"version": "8.18.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/ws/-/ws-8.18.1.tgz",
|
||||||
|
"integrity": "sha512-RKW2aJZMXeMxVpnZ6bck+RswznaxmzdULiBr6KY7XkTnW8uvt0iT9H5DkHUChXrc+uurzwa0rVI16n/Xzjdz1w==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10.0.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"bufferutil": "^4.0.1",
|
||||||
|
"utf-8-validate": ">=5.0.2"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"bufferutil": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"utf-8-validate": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/yocto-queue": {
|
"node_modules/yocto-queue": {
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz",
|
||||||
|
|||||||
17
package.json
17
package.json
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@playwright/mcp",
|
"name": "@playwright/mcp",
|
||||||
"version": "0.0.29",
|
"version": "0.0.33",
|
||||||
"description": "Playwright Tools for MCP",
|
"description": "Playwright Tools for MCP",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"repository": {
|
"repository": {
|
||||||
@@ -18,6 +18,7 @@
|
|||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "tsc",
|
"build": "tsc",
|
||||||
"lint": "npm run update-readme && eslint . && tsc --noEmit",
|
"lint": "npm run update-readme && eslint . && tsc --noEmit",
|
||||||
|
"lint-fix": "eslint . --fix",
|
||||||
"update-readme": "node utils/update-readme.js",
|
"update-readme": "node utils/update-readme.js",
|
||||||
"watch": "tsc --watch",
|
"watch": "tsc --watch",
|
||||||
"test": "playwright test",
|
"test": "playwright test",
|
||||||
@@ -36,26 +37,34 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@modelcontextprotocol/sdk": "^1.11.0",
|
"@modelcontextprotocol/sdk": "^1.16.0",
|
||||||
"commander": "^13.1.0",
|
"commander": "^13.1.0",
|
||||||
"debug": "^4.4.1",
|
"debug": "^4.4.1",
|
||||||
|
"dotenv": "^17.2.0",
|
||||||
"mime": "^4.0.7",
|
"mime": "^4.0.7",
|
||||||
"playwright": "1.53.0",
|
"playwright": "1.55.0-alpha-2025-08-07",
|
||||||
|
"playwright-core": "1.55.0-alpha-2025-08-07",
|
||||||
|
"ws": "^8.18.1",
|
||||||
|
"zod": "^3.24.1",
|
||||||
"zod-to-json-schema": "^3.24.4"
|
"zod-to-json-schema": "^3.24.4"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@anthropic-ai/sdk": "^0.57.0",
|
||||||
"@eslint/eslintrc": "^3.2.0",
|
"@eslint/eslintrc": "^3.2.0",
|
||||||
"@eslint/js": "^9.19.0",
|
"@eslint/js": "^9.19.0",
|
||||||
"@playwright/test": "1.53.0",
|
"@playwright/test": "1.55.0-alpha-2025-08-07",
|
||||||
"@stylistic/eslint-plugin": "^3.0.1",
|
"@stylistic/eslint-plugin": "^3.0.1",
|
||||||
"@types/debug": "^4.1.12",
|
"@types/debug": "^4.1.12",
|
||||||
"@types/node": "^22.13.10",
|
"@types/node": "^22.13.10",
|
||||||
|
"@types/ws": "^8.18.1",
|
||||||
"@typescript-eslint/eslint-plugin": "^8.26.1",
|
"@typescript-eslint/eslint-plugin": "^8.26.1",
|
||||||
"@typescript-eslint/parser": "^8.26.1",
|
"@typescript-eslint/parser": "^8.26.1",
|
||||||
"@typescript-eslint/utils": "^8.26.1",
|
"@typescript-eslint/utils": "^8.26.1",
|
||||||
|
"esbuild": "^0.20.1",
|
||||||
"eslint": "^9.19.0",
|
"eslint": "^9.19.0",
|
||||||
"eslint-plugin-import": "^2.31.0",
|
"eslint-plugin-import": "^2.31.0",
|
||||||
"eslint-plugin-notice": "^1.0.0",
|
"eslint-plugin-notice": "^1.0.0",
|
||||||
|
"openai": "^5.10.2",
|
||||||
"typescript": "^5.8.2"
|
"typescript": "^5.8.2"
|
||||||
},
|
},
|
||||||
"bin": {
|
"bin": {
|
||||||
|
|||||||
172
src/actions.d.ts
vendored
Normal file
172
src/actions.d.ts
vendored
Normal file
@@ -0,0 +1,172 @@
|
|||||||
|
/**
|
||||||
|
* Copyright (c) Microsoft Corporation.
|
||||||
|
*
|
||||||
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
* you may not use this file except in compliance with the License.
|
||||||
|
* You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing, software
|
||||||
|
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
* See the License for the specific language governing permissions and
|
||||||
|
* limitations under the License.
|
||||||
|
*/
|
||||||
|
|
||||||
|
type Point = { x: number, y: number };
|
||||||
|
|
||||||
|
export type ActionName =
|
||||||
|
'check' |
|
||||||
|
'click' |
|
||||||
|
'closePage' |
|
||||||
|
'fill' |
|
||||||
|
'navigate' |
|
||||||
|
'openPage' |
|
||||||
|
'press' |
|
||||||
|
'select' |
|
||||||
|
'uncheck' |
|
||||||
|
'setInputFiles' |
|
||||||
|
'assertText' |
|
||||||
|
'assertValue' |
|
||||||
|
'assertChecked' |
|
||||||
|
'assertVisible' |
|
||||||
|
'assertSnapshot';
|
||||||
|
|
||||||
|
export type ActionBase = {
|
||||||
|
name: ActionName,
|
||||||
|
signals: Signal[],
|
||||||
|
ariaSnapshot?: string,
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ActionWithSelector = ActionBase & {
|
||||||
|
selector: string,
|
||||||
|
ref?: string,
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ClickAction = ActionWithSelector & {
|
||||||
|
name: 'click',
|
||||||
|
button: 'left' | 'middle' | 'right',
|
||||||
|
modifiers: number,
|
||||||
|
clickCount: number,
|
||||||
|
position?: Point,
|
||||||
|
};
|
||||||
|
|
||||||
|
export type CheckAction = ActionWithSelector & {
|
||||||
|
name: 'check',
|
||||||
|
};
|
||||||
|
|
||||||
|
export type UncheckAction = ActionWithSelector & {
|
||||||
|
name: 'uncheck',
|
||||||
|
};
|
||||||
|
|
||||||
|
export type FillAction = ActionWithSelector & {
|
||||||
|
name: 'fill',
|
||||||
|
text: string,
|
||||||
|
};
|
||||||
|
|
||||||
|
export type NavigateAction = ActionBase & {
|
||||||
|
name: 'navigate',
|
||||||
|
url: string,
|
||||||
|
};
|
||||||
|
|
||||||
|
export type OpenPageAction = ActionBase & {
|
||||||
|
name: 'openPage',
|
||||||
|
url: string,
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ClosesPageAction = ActionBase & {
|
||||||
|
name: 'closePage',
|
||||||
|
};
|
||||||
|
|
||||||
|
export type PressAction = ActionWithSelector & {
|
||||||
|
name: 'press',
|
||||||
|
key: string,
|
||||||
|
modifiers: number,
|
||||||
|
};
|
||||||
|
|
||||||
|
export type SelectAction = ActionWithSelector & {
|
||||||
|
name: 'select',
|
||||||
|
options: string[],
|
||||||
|
};
|
||||||
|
|
||||||
|
export type SetInputFilesAction = ActionWithSelector & {
|
||||||
|
name: 'setInputFiles',
|
||||||
|
files: string[],
|
||||||
|
};
|
||||||
|
|
||||||
|
export type AssertTextAction = ActionWithSelector & {
|
||||||
|
name: 'assertText',
|
||||||
|
text: string,
|
||||||
|
substring: boolean,
|
||||||
|
};
|
||||||
|
|
||||||
|
export type AssertValueAction = ActionWithSelector & {
|
||||||
|
name: 'assertValue',
|
||||||
|
value: string,
|
||||||
|
};
|
||||||
|
|
||||||
|
export type AssertCheckedAction = ActionWithSelector & {
|
||||||
|
name: 'assertChecked',
|
||||||
|
checked: boolean,
|
||||||
|
};
|
||||||
|
|
||||||
|
export type AssertVisibleAction = ActionWithSelector & {
|
||||||
|
name: 'assertVisible',
|
||||||
|
};
|
||||||
|
|
||||||
|
export type AssertSnapshotAction = ActionWithSelector & {
|
||||||
|
name: 'assertSnapshot',
|
||||||
|
ariaSnapshot: string,
|
||||||
|
};
|
||||||
|
|
||||||
|
export type Action = ClickAction | CheckAction | ClosesPageAction | OpenPageAction | UncheckAction | FillAction | NavigateAction | PressAction | SelectAction | SetInputFilesAction | AssertTextAction | AssertValueAction | AssertCheckedAction | AssertVisibleAction | AssertSnapshotAction;
|
||||||
|
export type AssertAction = AssertCheckedAction | AssertValueAction | AssertTextAction | AssertVisibleAction | AssertSnapshotAction;
|
||||||
|
export type PerformOnRecordAction = ClickAction | CheckAction | UncheckAction | PressAction | SelectAction;
|
||||||
|
|
||||||
|
// Signals.
|
||||||
|
|
||||||
|
export type BaseSignal = {
|
||||||
|
};
|
||||||
|
|
||||||
|
export type NavigationSignal = BaseSignal & {
|
||||||
|
name: 'navigation',
|
||||||
|
url: string,
|
||||||
|
};
|
||||||
|
|
||||||
|
export type PopupSignal = BaseSignal & {
|
||||||
|
name: 'popup',
|
||||||
|
popupAlias: string,
|
||||||
|
};
|
||||||
|
|
||||||
|
export type DownloadSignal = BaseSignal & {
|
||||||
|
name: 'download',
|
||||||
|
downloadAlias: string,
|
||||||
|
};
|
||||||
|
|
||||||
|
export type DialogSignal = BaseSignal & {
|
||||||
|
name: 'dialog',
|
||||||
|
dialogAlias: string,
|
||||||
|
};
|
||||||
|
|
||||||
|
export type Signal = NavigationSignal | PopupSignal | DownloadSignal | DialogSignal;
|
||||||
|
|
||||||
|
export type FrameDescription = {
|
||||||
|
pageGuid: string;
|
||||||
|
pageAlias: string;
|
||||||
|
framePath: string[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ActionInContext = {
|
||||||
|
frame: FrameDescription;
|
||||||
|
description?: string;
|
||||||
|
action: Action;
|
||||||
|
startTime: number;
|
||||||
|
endTime?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type SignalInContext = {
|
||||||
|
frame: FrameDescription;
|
||||||
|
signal: Signal;
|
||||||
|
timestamp: number;
|
||||||
|
};
|
||||||
@@ -14,44 +14,48 @@
|
|||||||
* limitations under the License.
|
* limitations under the License.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import fs from 'node:fs';
|
import fs from 'fs';
|
||||||
import net from 'node:net';
|
import net from 'net';
|
||||||
import path from 'node:path';
|
import path from 'path';
|
||||||
import os from 'node:os';
|
|
||||||
|
|
||||||
import debug from 'debug';
|
|
||||||
import * as playwright from 'playwright';
|
import * as playwright from 'playwright';
|
||||||
import { userDataDir } from './fileUtils.js';
|
// @ts-ignore
|
||||||
|
import { registryDirectory } from 'playwright-core/lib/server/registry/index';
|
||||||
|
import { logUnhandledError, testDebug } from './log.js';
|
||||||
|
import { createHash } from './utils.js';
|
||||||
|
import { outputFile } from './config.js';
|
||||||
|
|
||||||
import type { FullConfig } from './config.js';
|
import type { FullConfig } from './config.js';
|
||||||
import type { BrowserInfo, LaunchBrowserRequest } from './browserServer.js';
|
|
||||||
|
|
||||||
const testDebug = debug('pw:mcp:test');
|
export function contextFactory(config: FullConfig): BrowserContextFactory {
|
||||||
|
if (config.browser.remoteEndpoint)
|
||||||
export function contextFactory(browserConfig: FullConfig['browser']): BrowserContextFactory {
|
return new RemoteContextFactory(config);
|
||||||
if (browserConfig.remoteEndpoint)
|
if (config.browser.cdpEndpoint)
|
||||||
return new RemoteContextFactory(browserConfig);
|
return new CdpContextFactory(config);
|
||||||
if (browserConfig.cdpEndpoint)
|
if (config.browser.isolated)
|
||||||
return new CdpContextFactory(browserConfig);
|
return new IsolatedContextFactory(config);
|
||||||
if (browserConfig.isolated)
|
return new PersistentContextFactory(config);
|
||||||
return new IsolatedContextFactory(browserConfig);
|
|
||||||
if (browserConfig.browserAgent)
|
|
||||||
return new BrowserServerContextFactory(browserConfig);
|
|
||||||
return new PersistentContextFactory(browserConfig);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type ClientInfo = { name?: string, version?: string, rootPath?: string };
|
||||||
|
|
||||||
export interface BrowserContextFactory {
|
export interface BrowserContextFactory {
|
||||||
createContext(): Promise<{ browserContext: playwright.BrowserContext, close: () => Promise<void> }>;
|
readonly name: string;
|
||||||
|
readonly description: string;
|
||||||
|
createContext(clientInfo: ClientInfo, abortSignal: AbortSignal): Promise<{ browserContext: playwright.BrowserContext, close: () => Promise<void> }>;
|
||||||
}
|
}
|
||||||
|
|
||||||
class BaseContextFactory implements BrowserContextFactory {
|
class BaseContextFactory implements BrowserContextFactory {
|
||||||
readonly browserConfig: FullConfig['browser'];
|
|
||||||
protected _browserPromise: Promise<playwright.Browser> | undefined;
|
|
||||||
readonly name: string;
|
readonly name: string;
|
||||||
|
readonly description: string;
|
||||||
|
readonly config: FullConfig;
|
||||||
|
protected _browserPromise: Promise<playwright.Browser> | undefined;
|
||||||
|
protected _tracesDir: string | undefined;
|
||||||
|
|
||||||
constructor(name: string, browserConfig: FullConfig['browser']) {
|
constructor(name: string, description: string, config: FullConfig) {
|
||||||
this.name = name;
|
this.name = name;
|
||||||
this.browserConfig = browserConfig;
|
this.description = description;
|
||||||
|
this.config = config;
|
||||||
}
|
}
|
||||||
|
|
||||||
protected async _obtainBrowser(): Promise<playwright.Browser> {
|
protected async _obtainBrowser(): Promise<playwright.Browser> {
|
||||||
@@ -73,7 +77,10 @@ class BaseContextFactory implements BrowserContextFactory {
|
|||||||
throw new Error('Not implemented');
|
throw new Error('Not implemented');
|
||||||
}
|
}
|
||||||
|
|
||||||
async createContext(): Promise<{ browserContext: playwright.BrowserContext, close: () => Promise<void> }> {
|
async createContext(clientInfo: ClientInfo): Promise<{ browserContext: playwright.BrowserContext, close: () => Promise<void> }> {
|
||||||
|
if (this.config.saveTrace)
|
||||||
|
this._tracesDir = await outputFile(this.config, clientInfo.rootPath, `traces-${Date.now()}`);
|
||||||
|
|
||||||
testDebug(`create browser context (${this.name})`);
|
testDebug(`create browser context (${this.name})`);
|
||||||
const browser = await this._obtainBrowser();
|
const browser = await this._obtainBrowser();
|
||||||
const browserContext = await this._doCreateContext(browser);
|
const browserContext = await this._doCreateContext(browser);
|
||||||
@@ -88,24 +95,25 @@ class BaseContextFactory implements BrowserContextFactory {
|
|||||||
testDebug(`close browser context (${this.name})`);
|
testDebug(`close browser context (${this.name})`);
|
||||||
if (browser.contexts().length === 1)
|
if (browser.contexts().length === 1)
|
||||||
this._browserPromise = undefined;
|
this._browserPromise = undefined;
|
||||||
await browserContext.close().catch(() => {});
|
await browserContext.close().catch(logUnhandledError);
|
||||||
if (browser.contexts().length === 0) {
|
if (browser.contexts().length === 0) {
|
||||||
testDebug(`close browser (${this.name})`);
|
testDebug(`close browser (${this.name})`);
|
||||||
await browser.close().catch(() => {});
|
await browser.close().catch(logUnhandledError);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class IsolatedContextFactory extends BaseContextFactory {
|
class IsolatedContextFactory extends BaseContextFactory {
|
||||||
constructor(browserConfig: FullConfig['browser']) {
|
constructor(config: FullConfig) {
|
||||||
super('isolated', browserConfig);
|
super('isolated', 'Create a new isolated browser context', config);
|
||||||
}
|
}
|
||||||
|
|
||||||
protected override async _doObtainBrowser(): Promise<playwright.Browser> {
|
protected override async _doObtainBrowser(): Promise<playwright.Browser> {
|
||||||
await injectCdpPort(this.browserConfig);
|
await injectCdpPort(this.config.browser);
|
||||||
const browserType = playwright[this.browserConfig.browserName];
|
const browserType = playwright[this.config.browser.browserName];
|
||||||
return browserType.launch({
|
return browserType.launch({
|
||||||
...this.browserConfig.launchOptions,
|
tracesDir: this._tracesDir,
|
||||||
|
...this.config.browser.launchOptions,
|
||||||
handleSIGINT: false,
|
handleSIGINT: false,
|
||||||
handleSIGTERM: false,
|
handleSIGTERM: false,
|
||||||
}).catch(error => {
|
}).catch(error => {
|
||||||
@@ -116,35 +124,35 @@ class IsolatedContextFactory extends BaseContextFactory {
|
|||||||
}
|
}
|
||||||
|
|
||||||
protected override async _doCreateContext(browser: playwright.Browser): Promise<playwright.BrowserContext> {
|
protected override async _doCreateContext(browser: playwright.Browser): Promise<playwright.BrowserContext> {
|
||||||
return browser.newContext(this.browserConfig.contextOptions);
|
return browser.newContext(this.config.browser.contextOptions);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class CdpContextFactory extends BaseContextFactory {
|
class CdpContextFactory extends BaseContextFactory {
|
||||||
constructor(browserConfig: FullConfig['browser']) {
|
constructor(config: FullConfig) {
|
||||||
super('cdp', browserConfig);
|
super('cdp', 'Connect to a browser over CDP', config);
|
||||||
}
|
}
|
||||||
|
|
||||||
protected override async _doObtainBrowser(): Promise<playwright.Browser> {
|
protected override async _doObtainBrowser(): Promise<playwright.Browser> {
|
||||||
return playwright.chromium.connectOverCDP(this.browserConfig.cdpEndpoint!);
|
return playwright.chromium.connectOverCDP(this.config.browser.cdpEndpoint!);
|
||||||
}
|
}
|
||||||
|
|
||||||
protected override async _doCreateContext(browser: playwright.Browser): Promise<playwright.BrowserContext> {
|
protected override async _doCreateContext(browser: playwright.Browser): Promise<playwright.BrowserContext> {
|
||||||
return this.browserConfig.isolated ? await browser.newContext() : browser.contexts()[0];
|
return this.config.browser.isolated ? await browser.newContext() : browser.contexts()[0];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class RemoteContextFactory extends BaseContextFactory {
|
class RemoteContextFactory extends BaseContextFactory {
|
||||||
constructor(browserConfig: FullConfig['browser']) {
|
constructor(config: FullConfig) {
|
||||||
super('remote', browserConfig);
|
super('remote', 'Connect to a browser using a remote endpoint', config);
|
||||||
}
|
}
|
||||||
|
|
||||||
protected override async _doObtainBrowser(): Promise<playwright.Browser> {
|
protected override async _doObtainBrowser(): Promise<playwright.Browser> {
|
||||||
const url = new URL(this.browserConfig.remoteEndpoint!);
|
const url = new URL(this.config.browser.remoteEndpoint!);
|
||||||
url.searchParams.set('browser', this.browserConfig.browserName);
|
url.searchParams.set('browser', this.config.browser.browserName);
|
||||||
if (this.browserConfig.launchOptions)
|
if (this.config.browser.launchOptions)
|
||||||
url.searchParams.set('launch-options', JSON.stringify(this.browserConfig.launchOptions));
|
url.searchParams.set('launch-options', JSON.stringify(this.config.browser.launchOptions));
|
||||||
return playwright[this.browserConfig.browserName].connect(String(url));
|
return playwright[this.config.browser.browserName].connect(String(url));
|
||||||
}
|
}
|
||||||
|
|
||||||
protected override async _doCreateContext(browser: playwright.Browser): Promise<playwright.BrowserContext> {
|
protected override async _doCreateContext(browser: playwright.Browser): Promise<playwright.BrowserContext> {
|
||||||
@@ -153,27 +161,34 @@ class RemoteContextFactory extends BaseContextFactory {
|
|||||||
}
|
}
|
||||||
|
|
||||||
class PersistentContextFactory implements BrowserContextFactory {
|
class PersistentContextFactory implements BrowserContextFactory {
|
||||||
readonly browserConfig: FullConfig['browser'];
|
readonly config: FullConfig;
|
||||||
|
readonly name = 'persistent';
|
||||||
|
readonly description = 'Create a new persistent browser context';
|
||||||
|
|
||||||
private _userDataDirs = new Set<string>();
|
private _userDataDirs = new Set<string>();
|
||||||
|
|
||||||
constructor(browserConfig: FullConfig['browser']) {
|
constructor(config: FullConfig) {
|
||||||
this.browserConfig = browserConfig;
|
this.config = config;
|
||||||
}
|
}
|
||||||
|
|
||||||
async createContext(): Promise<{ browserContext: playwright.BrowserContext, close: () => Promise<void> }> {
|
async createContext(clientInfo: ClientInfo): Promise<{ browserContext: playwright.BrowserContext, close: () => Promise<void> }> {
|
||||||
await injectCdpPort(this.browserConfig);
|
await injectCdpPort(this.config.browser);
|
||||||
testDebug('create browser context (persistent)');
|
testDebug('create browser context (persistent)');
|
||||||
const userDataDir = this.browserConfig.userDataDir ?? await this._createUserDataDir();
|
const userDataDir = this.config.browser.userDataDir ?? await this._createUserDataDir(clientInfo.rootPath);
|
||||||
|
let tracesDir: string | undefined;
|
||||||
|
if (this.config.saveTrace)
|
||||||
|
tracesDir = await outputFile(this.config, clientInfo.rootPath, `traces-${Date.now()}`);
|
||||||
|
|
||||||
this._userDataDirs.add(userDataDir);
|
this._userDataDirs.add(userDataDir);
|
||||||
testDebug('lock user data dir', userDataDir);
|
testDebug('lock user data dir', userDataDir);
|
||||||
|
|
||||||
const browserType = playwright[this.browserConfig.browserName];
|
const browserType = playwright[this.config.browser.browserName];
|
||||||
for (let i = 0; i < 5; i++) {
|
for (let i = 0; i < 5; i++) {
|
||||||
try {
|
try {
|
||||||
const browserContext = await browserType.launchPersistentContext(userDataDir, {
|
const browserContext = await browserType.launchPersistentContext(userDataDir, {
|
||||||
...this.browserConfig.launchOptions,
|
tracesDir,
|
||||||
...this.browserConfig.contextOptions,
|
...this.config.browser.launchOptions,
|
||||||
|
...this.config.browser.contextOptions,
|
||||||
handleSIGINT: false,
|
handleSIGINT: false,
|
||||||
handleSIGTERM: false,
|
handleSIGTERM: false,
|
||||||
});
|
});
|
||||||
@@ -201,60 +216,23 @@ class PersistentContextFactory implements BrowserContextFactory {
|
|||||||
testDebug('close browser context complete (persistent)');
|
testDebug('close browser context complete (persistent)');
|
||||||
}
|
}
|
||||||
|
|
||||||
private async _createUserDataDir() {
|
private async _createUserDataDir(rootPath: string | undefined) {
|
||||||
let cacheDirectory: string;
|
const dir = process.env.PWMCP_PROFILES_DIR_FOR_TEST ?? registryDirectory;
|
||||||
if (process.platform === 'linux')
|
const browserToken = this.config.browser.launchOptions?.channel ?? this.config.browser?.browserName;
|
||||||
cacheDirectory = process.env.XDG_CACHE_HOME || path.join(os.homedir(), '.cache');
|
// Hesitant putting hundreds of files into the user's workspace, so using it for hashing instead.
|
||||||
else if (process.platform === 'darwin')
|
const rootPathToken = rootPath ? `-${createHash(rootPath)}` : '';
|
||||||
cacheDirectory = path.join(os.homedir(), 'Library', 'Caches');
|
const result = path.join(dir, `mcp-${browserToken}${rootPathToken}`);
|
||||||
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 });
|
await fs.promises.mkdir(result, { recursive: true });
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export class BrowserServerContextFactory extends BaseContextFactory {
|
|
||||||
constructor(browserConfig: FullConfig['browser']) {
|
|
||||||
super('persistent', browserConfig);
|
|
||||||
}
|
|
||||||
|
|
||||||
protected override async _doObtainBrowser(): Promise<playwright.Browser> {
|
|
||||||
const response = await fetch(new URL(`/json/launch`, this.browserConfig.browserAgent), {
|
|
||||||
method: 'POST',
|
|
||||||
body: JSON.stringify({
|
|
||||||
browserType: this.browserConfig.browserName,
|
|
||||||
userDataDir: this.browserConfig.userDataDir ?? await this._createUserDataDir(),
|
|
||||||
launchOptions: this.browserConfig.launchOptions,
|
|
||||||
contextOptions: this.browserConfig.contextOptions,
|
|
||||||
} as LaunchBrowserRequest),
|
|
||||||
});
|
|
||||||
const info = await response.json() as BrowserInfo;
|
|
||||||
if (info.error)
|
|
||||||
throw new Error(info.error);
|
|
||||||
return await playwright.chromium.connectOverCDP(`http://localhost:${info.cdpPort}/`);
|
|
||||||
}
|
|
||||||
|
|
||||||
protected override async _doCreateContext(browser: playwright.Browser): Promise<playwright.BrowserContext> {
|
|
||||||
return this.browserConfig.isolated ? await browser.newContext() : browser.contexts()[0];
|
|
||||||
}
|
|
||||||
|
|
||||||
private async _createUserDataDir() {
|
|
||||||
const dir = await userDataDir(this.browserConfig);
|
|
||||||
await fs.promises.mkdir(dir, { recursive: true });
|
|
||||||
return dir;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function injectCdpPort(browserConfig: FullConfig['browser']) {
|
async function injectCdpPort(browserConfig: FullConfig['browser']) {
|
||||||
if (browserConfig.browserName === 'chromium')
|
if (browserConfig.browserName === 'chromium')
|
||||||
(browserConfig.launchOptions as any).cdpPort = await findFreePort();
|
(browserConfig.launchOptions as any).cdpPort = await findFreePort();
|
||||||
}
|
}
|
||||||
|
|
||||||
async function findFreePort() {
|
async function findFreePort(): Promise<number> {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
const server = net.createServer();
|
const server = net.createServer();
|
||||||
server.listen(0, () => {
|
server.listen(0, () => {
|
||||||
|
|||||||
@@ -1,197 +0,0 @@
|
|||||||
/**
|
|
||||||
* Copyright (c) Microsoft Corporation.
|
|
||||||
*
|
|
||||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
|
||||||
* you may not use this file except in compliance with the License.
|
|
||||||
* You may obtain a copy of the License at
|
|
||||||
*
|
|
||||||
* http://www.apache.org/licenses/LICENSE-2.0
|
|
||||||
*
|
|
||||||
* Unless required by applicable law or agreed to in writing, software
|
|
||||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
|
||||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
||||||
* See the License for the specific language governing permissions and
|
|
||||||
* limitations under the License.
|
|
||||||
*/
|
|
||||||
|
|
||||||
/* eslint-disable no-console */
|
|
||||||
|
|
||||||
import net from 'net';
|
|
||||||
|
|
||||||
import { program } from 'commander';
|
|
||||||
import playwright from 'playwright';
|
|
||||||
|
|
||||||
import { HttpServer } from './httpServer.js';
|
|
||||||
import { packageJSON } from './package.js';
|
|
||||||
|
|
||||||
import type http from 'http';
|
|
||||||
|
|
||||||
export type LaunchBrowserRequest = {
|
|
||||||
browserType: string;
|
|
||||||
userDataDir: string;
|
|
||||||
launchOptions: playwright.LaunchOptions;
|
|
||||||
contextOptions: playwright.BrowserContextOptions;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type BrowserInfo = {
|
|
||||||
browserType: string;
|
|
||||||
userDataDir: string;
|
|
||||||
cdpPort: number;
|
|
||||||
launchOptions: playwright.LaunchOptions;
|
|
||||||
contextOptions: playwright.BrowserContextOptions;
|
|
||||||
error?: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
type BrowserEntry = {
|
|
||||||
browser?: playwright.Browser;
|
|
||||||
info: BrowserInfo;
|
|
||||||
};
|
|
||||||
|
|
||||||
class BrowserServer {
|
|
||||||
private _server = new HttpServer();
|
|
||||||
private _entries: BrowserEntry[] = [];
|
|
||||||
|
|
||||||
constructor() {
|
|
||||||
this._setupExitHandler();
|
|
||||||
}
|
|
||||||
|
|
||||||
async start(port: number) {
|
|
||||||
await this._server.start({ port });
|
|
||||||
this._server.routePath('/json/list', (req, res) => {
|
|
||||||
this._handleJsonList(res);
|
|
||||||
});
|
|
||||||
this._server.routePath('/json/launch', async (req, res) => {
|
|
||||||
void this._handleLaunchBrowser(req, res).catch(e => console.error(e));
|
|
||||||
});
|
|
||||||
this._setEntries([]);
|
|
||||||
}
|
|
||||||
|
|
||||||
private _handleJsonList(res: http.ServerResponse) {
|
|
||||||
const list = this._entries.map(browser => browser.info);
|
|
||||||
res.end(JSON.stringify(list));
|
|
||||||
}
|
|
||||||
|
|
||||||
private async _handleLaunchBrowser(req: http.IncomingMessage, res: http.ServerResponse) {
|
|
||||||
const request = await readBody<LaunchBrowserRequest>(req);
|
|
||||||
let info = this._entries.map(entry => entry.info).find(info => info.userDataDir === request.userDataDir);
|
|
||||||
if (!info || info.error)
|
|
||||||
info = await this._newBrowser(request);
|
|
||||||
res.end(JSON.stringify(info));
|
|
||||||
}
|
|
||||||
|
|
||||||
private async _newBrowser(request: LaunchBrowserRequest): Promise<BrowserInfo> {
|
|
||||||
const cdpPort = await findFreePort();
|
|
||||||
(request.launchOptions as any).cdpPort = cdpPort;
|
|
||||||
const info: BrowserInfo = {
|
|
||||||
browserType: request.browserType,
|
|
||||||
userDataDir: request.userDataDir,
|
|
||||||
cdpPort,
|
|
||||||
launchOptions: request.launchOptions,
|
|
||||||
contextOptions: request.contextOptions,
|
|
||||||
};
|
|
||||||
|
|
||||||
const browserType = playwright[request.browserType as 'chromium' | 'firefox' | 'webkit'];
|
|
||||||
const { browser, error } = await browserType.launchPersistentContext(request.userDataDir, {
|
|
||||||
...request.launchOptions,
|
|
||||||
...request.contextOptions,
|
|
||||||
handleSIGINT: false,
|
|
||||||
handleSIGTERM: false,
|
|
||||||
}).then(context => {
|
|
||||||
return { browser: context.browser()!, error: undefined };
|
|
||||||
}).catch(error => {
|
|
||||||
return { browser: undefined, error: error.message };
|
|
||||||
});
|
|
||||||
this._setEntries([...this._entries, {
|
|
||||||
browser,
|
|
||||||
info: {
|
|
||||||
browserType: request.browserType,
|
|
||||||
userDataDir: request.userDataDir,
|
|
||||||
cdpPort,
|
|
||||||
launchOptions: request.launchOptions,
|
|
||||||
contextOptions: request.contextOptions,
|
|
||||||
error,
|
|
||||||
},
|
|
||||||
}]);
|
|
||||||
browser?.on('disconnected', () => {
|
|
||||||
this._setEntries(this._entries.filter(entry => entry.browser !== browser));
|
|
||||||
});
|
|
||||||
return info;
|
|
||||||
}
|
|
||||||
|
|
||||||
private _updateReport() {
|
|
||||||
// Clear the current line and move cursor to top of screen
|
|
||||||
process.stdout.write('\x1b[2J\x1b[H');
|
|
||||||
process.stdout.write(`Playwright Browser Server v${packageJSON.version}\n`);
|
|
||||||
process.stdout.write(`Listening on ${this._server.urlPrefix('human-readable')}\n\n`);
|
|
||||||
|
|
||||||
if (this._entries.length === 0) {
|
|
||||||
process.stdout.write('No browsers currently running\n');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
process.stdout.write('Running browsers:\n');
|
|
||||||
for (const entry of this._entries) {
|
|
||||||
const status = entry.browser ? 'running' : 'error';
|
|
||||||
const statusColor = entry.browser ? '\x1b[32m' : '\x1b[31m'; // green for running, red for error
|
|
||||||
process.stdout.write(`${statusColor}${entry.info.browserType}\x1b[0m (${entry.info.userDataDir}) - ${statusColor}${status}\x1b[0m\n`);
|
|
||||||
if (entry.info.error)
|
|
||||||
process.stdout.write(` Error: ${entry.info.error}\n`);
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
private _setEntries(entries: BrowserEntry[]) {
|
|
||||||
this._entries = entries;
|
|
||||||
this._updateReport();
|
|
||||||
}
|
|
||||||
|
|
||||||
private _setupExitHandler() {
|
|
||||||
let isExiting = false;
|
|
||||||
const handleExit = async () => {
|
|
||||||
if (isExiting)
|
|
||||||
return;
|
|
||||||
isExiting = true;
|
|
||||||
setTimeout(() => process.exit(0), 15000);
|
|
||||||
for (const entry of this._entries)
|
|
||||||
await entry.browser?.close().catch(() => {});
|
|
||||||
process.exit(0);
|
|
||||||
};
|
|
||||||
|
|
||||||
process.stdin.on('close', handleExit);
|
|
||||||
process.on('SIGINT', handleExit);
|
|
||||||
process.on('SIGTERM', handleExit);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
program
|
|
||||||
.name('browser-agent')
|
|
||||||
.option('-p, --port <port>', 'Port to listen on', '9224')
|
|
||||||
.action(async options => {
|
|
||||||
await main(options);
|
|
||||||
});
|
|
||||||
|
|
||||||
void program.parseAsync(process.argv);
|
|
||||||
|
|
||||||
async function main(options: { port: string }) {
|
|
||||||
const server = new BrowserServer();
|
|
||||||
await server.start(+options.port);
|
|
||||||
}
|
|
||||||
|
|
||||||
function readBody<T>(req: http.IncomingMessage): Promise<T> {
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
const chunks: Buffer[] = [];
|
|
||||||
req.on('data', (chunk: Buffer) => chunks.push(chunk));
|
|
||||||
req.on('end', () => resolve(JSON.parse(Buffer.concat(chunks).toString())));
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async function findFreePort(): Promise<number> {
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
const server = net.createServer();
|
|
||||||
server.listen(0, () => {
|
|
||||||
const { port } = server.address() as net.AddressInfo;
|
|
||||||
server.close(() => resolve(port));
|
|
||||||
});
|
|
||||||
server.on('error', reject);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
142
src/browserServerBackend.ts
Normal file
142
src/browserServerBackend.ts
Normal file
@@ -0,0 +1,142 @@
|
|||||||
|
/**
|
||||||
|
* Copyright (c) Microsoft Corporation.
|
||||||
|
*
|
||||||
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
* you may not use this file except in compliance with the License.
|
||||||
|
* You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing, software
|
||||||
|
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
* See the License for the specific language governing permissions and
|
||||||
|
* limitations under the License.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { fileURLToPath } from 'url';
|
||||||
|
import { z } from 'zod';
|
||||||
|
import { FullConfig } from './config.js';
|
||||||
|
import { Context } from './context.js';
|
||||||
|
import { logUnhandledError } from './log.js';
|
||||||
|
import { Response } from './response.js';
|
||||||
|
import { SessionLog } from './sessionLog.js';
|
||||||
|
import { filteredTools } from './tools.js';
|
||||||
|
import { packageJSON } from './package.js';
|
||||||
|
import { defineTool } from './tools/tool.js';
|
||||||
|
|
||||||
|
import type { Tool } from './tools/tool.js';
|
||||||
|
import type { BrowserContextFactory } from './browserContextFactory.js';
|
||||||
|
import type * as mcpServer from './mcp/server.js';
|
||||||
|
import type { ServerBackend } from './mcp/server.js';
|
||||||
|
|
||||||
|
type NonEmptyArray<T> = [T, ...T[]];
|
||||||
|
|
||||||
|
export type FactoryList = NonEmptyArray<BrowserContextFactory>;
|
||||||
|
|
||||||
|
export class BrowserServerBackend implements ServerBackend {
|
||||||
|
name = 'Playwright';
|
||||||
|
version = packageJSON.version;
|
||||||
|
|
||||||
|
private _tools: Tool[];
|
||||||
|
private _context: Context | undefined;
|
||||||
|
private _sessionLog: SessionLog | undefined;
|
||||||
|
private _config: FullConfig;
|
||||||
|
private _browserContextFactory: BrowserContextFactory;
|
||||||
|
|
||||||
|
constructor(config: FullConfig, factories: FactoryList) {
|
||||||
|
this._config = config;
|
||||||
|
this._browserContextFactory = factories[0];
|
||||||
|
this._tools = filteredTools(config);
|
||||||
|
if (factories.length > 1)
|
||||||
|
this._tools.push(this._defineContextSwitchTool(factories));
|
||||||
|
}
|
||||||
|
|
||||||
|
async initialize(server: mcpServer.Server): Promise<void> {
|
||||||
|
const capabilities = server.getClientCapabilities() as mcpServer.ClientCapabilities;
|
||||||
|
let rootPath: string | undefined;
|
||||||
|
if (capabilities.roots && (
|
||||||
|
server.getClientVersion()?.name === 'Visual Studio Code' ||
|
||||||
|
server.getClientVersion()?.name === 'Visual Studio Code - Insiders')) {
|
||||||
|
const { roots } = await server.listRoots();
|
||||||
|
const firstRootUri = roots[0]?.uri;
|
||||||
|
const url = firstRootUri ? new URL(firstRootUri) : undefined;
|
||||||
|
rootPath = url ? fileURLToPath(url) : undefined;
|
||||||
|
}
|
||||||
|
this._sessionLog = this._config.saveSession ? await SessionLog.create(this._config, rootPath) : undefined;
|
||||||
|
this._context = new Context({
|
||||||
|
tools: this._tools,
|
||||||
|
config: this._config,
|
||||||
|
browserContextFactory: this._browserContextFactory,
|
||||||
|
sessionLog: this._sessionLog,
|
||||||
|
clientInfo: { ...server.getClientVersion(), rootPath },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
tools(): mcpServer.ToolSchema<any>[] {
|
||||||
|
return this._tools.map(tool => tool.schema);
|
||||||
|
}
|
||||||
|
|
||||||
|
async callTool(schema: mcpServer.ToolSchema<any>, parsedArguments: any) {
|
||||||
|
const context = this._context!;
|
||||||
|
const response = new Response(context, schema.name, parsedArguments);
|
||||||
|
const tool = this._tools.find(tool => tool.schema.name === schema.name)!;
|
||||||
|
context.setRunningTool(true);
|
||||||
|
try {
|
||||||
|
await tool.handle(context, parsedArguments, response);
|
||||||
|
await response.finish();
|
||||||
|
this._sessionLog?.logResponse(response);
|
||||||
|
} catch (error: any) {
|
||||||
|
response.addError(String(error));
|
||||||
|
} finally {
|
||||||
|
context.setRunningTool(false);
|
||||||
|
}
|
||||||
|
return response.serialize();
|
||||||
|
}
|
||||||
|
|
||||||
|
serverClosed() {
|
||||||
|
void this._context!.dispose().catch(logUnhandledError);
|
||||||
|
}
|
||||||
|
|
||||||
|
private _defineContextSwitchTool(factories: FactoryList): Tool<any> {
|
||||||
|
const self = this;
|
||||||
|
return defineTool({
|
||||||
|
capability: 'core',
|
||||||
|
|
||||||
|
schema: {
|
||||||
|
name: 'browser_connect',
|
||||||
|
title: 'Connect to a browser context',
|
||||||
|
description: [
|
||||||
|
'Connect to a browser using one of the available methods:',
|
||||||
|
...factories.map(factory => `- "${factory.name}": ${factory.description}`),
|
||||||
|
].join('\n'),
|
||||||
|
inputSchema: z.object({
|
||||||
|
method: z.enum(factories.map(factory => factory.name) as [string, ...string[]]).default(factories[0].name).describe('The method to use to connect to the browser'),
|
||||||
|
}),
|
||||||
|
type: 'readOnly',
|
||||||
|
},
|
||||||
|
|
||||||
|
async handle(context, params, response) {
|
||||||
|
const factory = factories.find(factory => factory.name === params.method);
|
||||||
|
if (!factory) {
|
||||||
|
response.addError('Unknown connection method: ' + params.method);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await self._setContextFactory(factory);
|
||||||
|
response.addResult('Successfully changed connection method.');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private async _setContextFactory(newFactory: BrowserContextFactory) {
|
||||||
|
if (this._context) {
|
||||||
|
const options = {
|
||||||
|
...this._context.options,
|
||||||
|
browserContextFactory: newFactory,
|
||||||
|
};
|
||||||
|
await this._context.dispose();
|
||||||
|
this._context = new Context(options);
|
||||||
|
}
|
||||||
|
this._browserContextFactory = newFactory;
|
||||||
|
}
|
||||||
|
}
|
||||||
108
src/config.ts
108
src/config.ts
@@ -18,18 +18,17 @@ import fs from 'fs';
|
|||||||
import os from 'os';
|
import os from 'os';
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
import { devices } from 'playwright';
|
import { devices } from 'playwright';
|
||||||
|
import { sanitizeForFilePath } from './utils.js';
|
||||||
|
|
||||||
import type { Config, ToolCapability } from '../config.js';
|
import type { Config, ToolCapability } from '../config.js';
|
||||||
import type { BrowserContextOptions, LaunchOptions } from 'playwright';
|
import type { BrowserContextOptions, LaunchOptions } from 'playwright';
|
||||||
import { sanitizeForFilePath } from './tools/utils.js';
|
|
||||||
|
|
||||||
export type CLIOptions = {
|
export type CLIOptions = {
|
||||||
allowedOrigins?: string[];
|
allowedOrigins?: string[];
|
||||||
blockedOrigins?: string[];
|
blockedOrigins?: string[];
|
||||||
blockServiceWorkers?: boolean;
|
blockServiceWorkers?: boolean;
|
||||||
browser?: string;
|
browser?: string;
|
||||||
browserAgent?: string;
|
caps?: string[];
|
||||||
caps?: string;
|
|
||||||
cdpEndpoint?: string;
|
cdpEndpoint?: string;
|
||||||
config?: string;
|
config?: string;
|
||||||
device?: string;
|
device?: string;
|
||||||
@@ -38,18 +37,18 @@ export type CLIOptions = {
|
|||||||
host?: string;
|
host?: string;
|
||||||
ignoreHttpsErrors?: boolean;
|
ignoreHttpsErrors?: boolean;
|
||||||
isolated?: boolean;
|
isolated?: boolean;
|
||||||
imageResponses?: 'allow' | 'omit' | 'auto';
|
imageResponses?: 'allow' | 'omit';
|
||||||
sandbox: boolean;
|
sandbox?: boolean;
|
||||||
outputDir?: string;
|
outputDir?: string;
|
||||||
port?: number;
|
port?: number;
|
||||||
proxyBypass?: string;
|
proxyBypass?: string;
|
||||||
proxyServer?: string;
|
proxyServer?: string;
|
||||||
|
saveSession?: boolean;
|
||||||
saveTrace?: boolean;
|
saveTrace?: boolean;
|
||||||
storageState?: string;
|
storageState?: string;
|
||||||
userAgent?: string;
|
userAgent?: string;
|
||||||
userDataDir?: string;
|
userDataDir?: string;
|
||||||
viewportSize?: string;
|
viewportSize?: string;
|
||||||
vision?: boolean;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const defaultConfig: FullConfig = {
|
const defaultConfig: FullConfig = {
|
||||||
@@ -69,7 +68,7 @@ const defaultConfig: FullConfig = {
|
|||||||
blockedOrigins: undefined,
|
blockedOrigins: undefined,
|
||||||
},
|
},
|
||||||
server: {},
|
server: {},
|
||||||
outputDir: path.join(os.tmpdir(), 'playwright-mcp-output', sanitizeForFilePath(new Date().toISOString())),
|
saveTrace: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
type BrowserUserConfig = NonNullable<Config['browser']>;
|
type BrowserUserConfig = NonNullable<Config['browser']>;
|
||||||
@@ -81,7 +80,7 @@ export type FullConfig = Config & {
|
|||||||
contextOptions: NonNullable<BrowserUserConfig['contextOptions']>;
|
contextOptions: NonNullable<BrowserUserConfig['contextOptions']>;
|
||||||
},
|
},
|
||||||
network: NonNullable<Config['network']>,
|
network: NonNullable<Config['network']>,
|
||||||
outputDir: string;
|
saveTrace: boolean;
|
||||||
server: NonNullable<Config['server']>,
|
server: NonNullable<Config['server']>,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -91,15 +90,16 @@ export async function resolveConfig(config: Config): Promise<FullConfig> {
|
|||||||
|
|
||||||
export async function resolveCLIConfig(cliOptions: CLIOptions): Promise<FullConfig> {
|
export async function resolveCLIConfig(cliOptions: CLIOptions): Promise<FullConfig> {
|
||||||
const configInFile = await loadConfig(cliOptions.config);
|
const configInFile = await loadConfig(cliOptions.config);
|
||||||
const cliOverrides = await configFromCLIOptions(cliOptions);
|
const envOverrides = configFromEnv();
|
||||||
const result = mergeConfig(mergeConfig(defaultConfig, configInFile), cliOverrides);
|
const cliOverrides = configFromCLIOptions(cliOptions);
|
||||||
// Derive artifact output directory from config.outputDir
|
let result = defaultConfig;
|
||||||
if (result.saveTrace)
|
result = mergeConfig(result, configInFile);
|
||||||
result.browser.launchOptions.tracesDir = path.join(result.outputDir, 'traces');
|
result = mergeConfig(result, envOverrides);
|
||||||
|
result = mergeConfig(result, cliOverrides);
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function configFromCLIOptions(cliOptions: CLIOptions): Promise<Config> {
|
export function configFromCLIOptions(cliOptions: CLIOptions): Config {
|
||||||
let browserName: 'chromium' | 'firefox' | 'webkit' | undefined;
|
let browserName: 'chromium' | 'firefox' | 'webkit' | undefined;
|
||||||
let channel: string | undefined;
|
let channel: string | undefined;
|
||||||
switch (cliOptions.browser) {
|
switch (cliOptions.browser) {
|
||||||
@@ -131,7 +131,7 @@ export async function configFromCLIOptions(cliOptions: CLIOptions): Promise<Conf
|
|||||||
};
|
};
|
||||||
|
|
||||||
// --no-sandbox was passed, disable the sandbox
|
// --no-sandbox was passed, disable the sandbox
|
||||||
if (!cliOptions.sandbox)
|
if (cliOptions.sandbox === false)
|
||||||
launchOptions.chromiumSandbox = false;
|
launchOptions.chromiumSandbox = false;
|
||||||
|
|
||||||
if (cliOptions.proxyServer) {
|
if (cliOptions.proxyServer) {
|
||||||
@@ -142,6 +142,9 @@ export async function configFromCLIOptions(cliOptions: CLIOptions): Promise<Conf
|
|||||||
launchOptions.proxy.bypass = cliOptions.proxyBypass;
|
launchOptions.proxy.bypass = cliOptions.proxyBypass;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (cliOptions.device && cliOptions.cdpEndpoint)
|
||||||
|
throw new Error('Device emulation is not supported with cdpEndpoint.');
|
||||||
|
|
||||||
// Context options
|
// Context options
|
||||||
const contextOptions: BrowserContextOptions = cliOptions.device ? devices[cliOptions.device] : {};
|
const contextOptions: BrowserContextOptions = cliOptions.device ? devices[cliOptions.device] : {};
|
||||||
if (cliOptions.storageState)
|
if (cliOptions.storageState)
|
||||||
@@ -169,7 +172,6 @@ export async function configFromCLIOptions(cliOptions: CLIOptions): Promise<Conf
|
|||||||
|
|
||||||
const result: Config = {
|
const result: Config = {
|
||||||
browser: {
|
browser: {
|
||||||
browserAgent: cliOptions.browserAgent ?? process.env.PW_BROWSER_AGENT,
|
|
||||||
browserName,
|
browserName,
|
||||||
isolated: cliOptions.isolated,
|
isolated: cliOptions.isolated,
|
||||||
userDataDir: cliOptions.userDataDir,
|
userDataDir: cliOptions.userDataDir,
|
||||||
@@ -181,12 +183,12 @@ export async function configFromCLIOptions(cliOptions: CLIOptions): Promise<Conf
|
|||||||
port: cliOptions.port,
|
port: cliOptions.port,
|
||||||
host: cliOptions.host,
|
host: cliOptions.host,
|
||||||
},
|
},
|
||||||
capabilities: cliOptions.caps?.split(',').map((c: string) => c.trim() as ToolCapability),
|
capabilities: cliOptions.caps as ToolCapability[],
|
||||||
vision: !!cliOptions.vision,
|
|
||||||
network: {
|
network: {
|
||||||
allowedOrigins: cliOptions.allowedOrigins,
|
allowedOrigins: cliOptions.allowedOrigins,
|
||||||
blockedOrigins: cliOptions.blockedOrigins,
|
blockedOrigins: cliOptions.blockedOrigins,
|
||||||
},
|
},
|
||||||
|
saveSession: cliOptions.saveSession,
|
||||||
saveTrace: cliOptions.saveTrace,
|
saveTrace: cliOptions.saveTrace,
|
||||||
outputDir: cliOptions.outputDir,
|
outputDir: cliOptions.outputDir,
|
||||||
imageResponses: cliOptions.imageResponses,
|
imageResponses: cliOptions.imageResponses,
|
||||||
@@ -195,6 +197,36 @@ export async function configFromCLIOptions(cliOptions: CLIOptions): Promise<Conf
|
|||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function configFromEnv(): Config {
|
||||||
|
const options: CLIOptions = {};
|
||||||
|
options.allowedOrigins = semicolonSeparatedList(process.env.PLAYWRIGHT_MCP_ALLOWED_ORIGINS);
|
||||||
|
options.blockedOrigins = semicolonSeparatedList(process.env.PLAYWRIGHT_MCP_BLOCKED_ORIGINS);
|
||||||
|
options.blockServiceWorkers = envToBoolean(process.env.PLAYWRIGHT_MCP_BLOCK_SERVICE_WORKERS);
|
||||||
|
options.browser = envToString(process.env.PLAYWRIGHT_MCP_BROWSER);
|
||||||
|
options.caps = commaSeparatedList(process.env.PLAYWRIGHT_MCP_CAPS);
|
||||||
|
options.cdpEndpoint = envToString(process.env.PLAYWRIGHT_MCP_CDP_ENDPOINT);
|
||||||
|
options.config = envToString(process.env.PLAYWRIGHT_MCP_CONFIG);
|
||||||
|
options.device = envToString(process.env.PLAYWRIGHT_MCP_DEVICE);
|
||||||
|
options.executablePath = envToString(process.env.PLAYWRIGHT_MCP_EXECUTABLE_PATH);
|
||||||
|
options.headless = envToBoolean(process.env.PLAYWRIGHT_MCP_HEADLESS);
|
||||||
|
options.host = envToString(process.env.PLAYWRIGHT_MCP_HOST);
|
||||||
|
options.ignoreHttpsErrors = envToBoolean(process.env.PLAYWRIGHT_MCP_IGNORE_HTTPS_ERRORS);
|
||||||
|
options.isolated = envToBoolean(process.env.PLAYWRIGHT_MCP_ISOLATED);
|
||||||
|
if (process.env.PLAYWRIGHT_MCP_IMAGE_RESPONSES === 'omit')
|
||||||
|
options.imageResponses = 'omit';
|
||||||
|
options.sandbox = envToBoolean(process.env.PLAYWRIGHT_MCP_SANDBOX);
|
||||||
|
options.outputDir = envToString(process.env.PLAYWRIGHT_MCP_OUTPUT_DIR);
|
||||||
|
options.port = envToNumber(process.env.PLAYWRIGHT_MCP_PORT);
|
||||||
|
options.proxyBypass = envToString(process.env.PLAYWRIGHT_MCP_PROXY_BYPASS);
|
||||||
|
options.proxyServer = envToString(process.env.PLAYWRIGHT_MCP_PROXY_SERVER);
|
||||||
|
options.saveTrace = envToBoolean(process.env.PLAYWRIGHT_MCP_SAVE_TRACE);
|
||||||
|
options.storageState = envToString(process.env.PLAYWRIGHT_MCP_STORAGE_STATE);
|
||||||
|
options.userAgent = envToString(process.env.PLAYWRIGHT_MCP_USER_AGENT);
|
||||||
|
options.userDataDir = envToString(process.env.PLAYWRIGHT_MCP_USER_DATA_DIR);
|
||||||
|
options.viewportSize = envToString(process.env.PLAYWRIGHT_MCP_VIEWPORT_SIZE);
|
||||||
|
return configFromCLIOptions(options);
|
||||||
|
}
|
||||||
|
|
||||||
async function loadConfig(configFile: string | undefined): Promise<Config> {
|
async function loadConfig(configFile: string | undefined): Promise<Config> {
|
||||||
if (!configFile)
|
if (!configFile)
|
||||||
return {};
|
return {};
|
||||||
@@ -206,10 +238,14 @@ async function loadConfig(configFile: string | undefined): Promise<Config> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function outputFile(config: FullConfig, name: string): Promise<string> {
|
export async function outputFile(config: FullConfig, rootPath: string | undefined, name: string): Promise<string> {
|
||||||
await fs.promises.mkdir(config.outputDir, { recursive: true });
|
const outputDir = config.outputDir
|
||||||
|
?? (rootPath ? path.join(rootPath, '.playwright-mcp') : undefined)
|
||||||
|
?? path.join(os.tmpdir(), 'playwright-mcp-output', sanitizeForFilePath(new Date().toISOString()));
|
||||||
|
|
||||||
|
await fs.promises.mkdir(outputDir, { recursive: true });
|
||||||
const fileName = sanitizeForFilePath(name);
|
const fileName = sanitizeForFilePath(name);
|
||||||
return path.join(config.outputDir, fileName);
|
return path.join(outputDir, fileName);
|
||||||
}
|
}
|
||||||
|
|
||||||
function pickDefined<T extends object>(obj: T | undefined): Partial<T> {
|
function pickDefined<T extends object>(obj: T | undefined): Partial<T> {
|
||||||
@@ -252,3 +288,33 @@ function mergeConfig(base: FullConfig, overrides: Config): FullConfig {
|
|||||||
},
|
},
|
||||||
} as FullConfig;
|
} as FullConfig;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function semicolonSeparatedList(value: string | undefined): string[] | undefined {
|
||||||
|
if (!value)
|
||||||
|
return undefined;
|
||||||
|
return value.split(';').map(v => v.trim());
|
||||||
|
}
|
||||||
|
|
||||||
|
export function commaSeparatedList(value: string | undefined): string[] | undefined {
|
||||||
|
if (!value)
|
||||||
|
return undefined;
|
||||||
|
return value.split(',').map(v => v.trim());
|
||||||
|
}
|
||||||
|
|
||||||
|
function envToNumber(value: string | undefined): number | undefined {
|
||||||
|
if (!value)
|
||||||
|
return undefined;
|
||||||
|
return +value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function envToBoolean(value: string | undefined): boolean | undefined {
|
||||||
|
if (value === 'true' || value === '1')
|
||||||
|
return true;
|
||||||
|
if (value === 'false' || value === '0')
|
||||||
|
return false;
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function envToString(value: string | undefined): string | undefined {
|
||||||
|
return value ? value.trim() : undefined;
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,98 +0,0 @@
|
|||||||
/**
|
|
||||||
* Copyright (c) Microsoft Corporation.
|
|
||||||
*
|
|
||||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
|
||||||
* you may not use this file except in compliance with the License.
|
|
||||||
* You may obtain a copy of the License at
|
|
||||||
*
|
|
||||||
* http://www.apache.org/licenses/LICENSE-2.0
|
|
||||||
*
|
|
||||||
* Unless required by applicable law or agreed to in writing, software
|
|
||||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
|
||||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
||||||
* See the License for the specific language governing permissions and
|
|
||||||
* limitations under the License.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { 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();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
341
src/context.ts
341
src/context.ts
@@ -17,79 +17,68 @@
|
|||||||
import debug from 'debug';
|
import debug from 'debug';
|
||||||
import * as playwright from 'playwright';
|
import * as playwright from 'playwright';
|
||||||
|
|
||||||
import { callOnPageNoTrace, waitForCompletion } from './tools/utils.js';
|
import { logUnhandledError } from './log.js';
|
||||||
import { ManualPromise } from './manualPromise.js';
|
|
||||||
import { Tab } from './tab.js';
|
import { Tab } from './tab.js';
|
||||||
import { outputFile } from './config.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 { FullConfig } from './config.js';
|
import type { FullConfig } from './config.js';
|
||||||
import type { BrowserContextFactory } from './browserContextFactory.js';
|
import type { Tool } from './tools/tool.js';
|
||||||
|
import type { BrowserContextFactory, ClientInfo } from './browserContextFactory.js';
|
||||||
type PendingAction = {
|
import type * as actions from './actions.js';
|
||||||
dialogShown: ManualPromise<void>;
|
import type { SessionLog } from './sessionLog.js';
|
||||||
};
|
|
||||||
|
|
||||||
const testDebug = debug('pw:mcp:test');
|
const testDebug = debug('pw:mcp:test');
|
||||||
|
|
||||||
|
type ContextOptions = {
|
||||||
|
tools: Tool[];
|
||||||
|
config: FullConfig;
|
||||||
|
browserContextFactory: BrowserContextFactory;
|
||||||
|
sessionLog: SessionLog | undefined;
|
||||||
|
clientInfo: ClientInfo;
|
||||||
|
};
|
||||||
|
|
||||||
export class Context {
|
export class Context {
|
||||||
readonly tools: Tool[];
|
readonly tools: Tool[];
|
||||||
readonly config: FullConfig;
|
readonly config: FullConfig;
|
||||||
|
readonly sessionLog: SessionLog | undefined;
|
||||||
|
readonly options: ContextOptions;
|
||||||
private _browserContextPromise: Promise<{ browserContext: playwright.BrowserContext, close: () => Promise<void> }> | undefined;
|
private _browserContextPromise: Promise<{ browserContext: playwright.BrowserContext, close: () => Promise<void> }> | undefined;
|
||||||
private _browserContextFactory: BrowserContextFactory;
|
private _browserContextFactory: BrowserContextFactory;
|
||||||
private _tabs: Tab[] = [];
|
private _tabs: Tab[] = [];
|
||||||
private _currentTab: Tab | undefined;
|
private _currentTab: Tab | undefined;
|
||||||
private _modalStates: (ModalState & { tab: Tab })[] = [];
|
private _clientInfo: ClientInfo;
|
||||||
private _pendingAction: PendingAction | undefined;
|
|
||||||
private _downloads: { download: playwright.Download, finished: boolean, outputFile: string }[] = [];
|
|
||||||
clientVersion: { name: string; version: string; } | undefined;
|
|
||||||
|
|
||||||
constructor(tools: Tool[], config: FullConfig, browserContextFactory: BrowserContextFactory) {
|
private static _allContexts: Set<Context> = new Set();
|
||||||
this.tools = tools;
|
private _closeBrowserContextPromise: Promise<void> | undefined;
|
||||||
this.config = config;
|
private _isRunningTool: boolean = false;
|
||||||
this._browserContextFactory = browserContextFactory;
|
private _abortController = new AbortController();
|
||||||
|
|
||||||
|
constructor(options: ContextOptions) {
|
||||||
|
this.tools = options.tools;
|
||||||
|
this.config = options.config;
|
||||||
|
this.sessionLog = options.sessionLog;
|
||||||
|
this.options = options;
|
||||||
|
this._browserContextFactory = options.browserContextFactory;
|
||||||
|
this._clientInfo = options.clientInfo;
|
||||||
testDebug('create context');
|
testDebug('create context');
|
||||||
|
Context._allContexts.add(this);
|
||||||
}
|
}
|
||||||
|
|
||||||
clientSupportsImages(): boolean {
|
static async disposeAll() {
|
||||||
if (this.config.imageResponses === 'allow')
|
await Promise.all([...Context._allContexts].map(context => context.dispose()));
|
||||||
return true;
|
|
||||||
if (this.config.imageResponses === 'omit')
|
|
||||||
return false;
|
|
||||||
return !this.clientVersion?.name.includes('cursor');
|
|
||||||
}
|
|
||||||
|
|
||||||
modalStates(): ModalState[] {
|
|
||||||
return this._modalStates;
|
|
||||||
}
|
|
||||||
|
|
||||||
setModalState(modalState: ModalState, inTab: Tab) {
|
|
||||||
this._modalStates.push({ ...modalState, tab: inTab });
|
|
||||||
}
|
|
||||||
|
|
||||||
clearModalState(modalState: ModalState) {
|
|
||||||
this._modalStates = this._modalStates.filter(state => state !== modalState);
|
|
||||||
}
|
|
||||||
|
|
||||||
modalStatesMarkdown(): string[] {
|
|
||||||
const result: string[] = ['### Modal state'];
|
|
||||||
if (this._modalStates.length === 0)
|
|
||||||
result.push('- There is no modal state present');
|
|
||||||
for (const state of this._modalStates) {
|
|
||||||
const tool = this.tools.find(tool => tool.clearsModalState === state.type);
|
|
||||||
result.push(`- [${state.description}]: can be handled by the "${tool?.schema.name}" tool`);
|
|
||||||
}
|
|
||||||
return result;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
tabs(): Tab[] {
|
tabs(): Tab[] {
|
||||||
return this._tabs;
|
return this._tabs;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
currentTab(): Tab | undefined {
|
||||||
|
return this._currentTab;
|
||||||
|
}
|
||||||
|
|
||||||
currentTabOrDie(): Tab {
|
currentTabOrDie(): Tab {
|
||||||
if (!this._currentTab)
|
if (!this._currentTab)
|
||||||
throw new Error('No current snapshot available. Capture a snapshot or navigate to a new location first.');
|
throw new Error('No open pages available. Use the "browser_navigate" tool to navigate to a page first.');
|
||||||
return this._currentTab;
|
return this._currentTab;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -101,8 +90,12 @@ export class Context {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async selectTab(index: number) {
|
async selectTab(index: number) {
|
||||||
this._currentTab = this._tabs[index - 1];
|
const tab = this._tabs[index];
|
||||||
await this._currentTab.page.bringToFront();
|
if (!tab)
|
||||||
|
throw new Error(`Tab ${index} not found`);
|
||||||
|
await tab.page.bringToFront();
|
||||||
|
this._currentTab = tab;
|
||||||
|
return tab;
|
||||||
}
|
}
|
||||||
|
|
||||||
async ensureTab(): Promise<Tab> {
|
async ensureTab(): Promise<Tab> {
|
||||||
@@ -112,162 +105,17 @@ export class Context {
|
|||||||
return this._currentTab!;
|
return this._currentTab!;
|
||||||
}
|
}
|
||||||
|
|
||||||
async listTabsMarkdown(): Promise<string> {
|
async closeTab(index: number | undefined): Promise<string> {
|
||||||
if (!this._tabs.length)
|
const tab = index === undefined ? this._currentTab : this._tabs[index];
|
||||||
return '### No tabs open';
|
if (!tab)
|
||||||
const lines: string[] = ['### Open tabs'];
|
throw new Error(`Tab ${index} not found`);
|
||||||
for (let i = 0; i < this._tabs.length; i++) {
|
const url = tab.page.url();
|
||||||
const tab = this._tabs[i];
|
await tab.page.close();
|
||||||
const title = await tab.title();
|
return url;
|
||||||
const url = tab.page.url();
|
|
||||||
const current = tab === this._currentTab ? ' (current)' : '';
|
|
||||||
lines.push(`- ${i + 1}:${current} [${title}] (${url})`);
|
|
||||||
}
|
|
||||||
return lines.join('\n');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async closeTab(index: number | undefined) {
|
async outputFile(name: string): Promise<string> {
|
||||||
const tab = index === undefined ? this._currentTab : this._tabs[index - 1];
|
return outputFile(this.config, this._clientInfo.rootPath, name);
|
||||||
await tab?.page.close();
|
|
||||||
return await this.listTabsMarkdown();
|
|
||||||
}
|
|
||||||
|
|
||||||
async run(tool: Tool, params: Record<string, unknown> | undefined) {
|
|
||||||
// Tab management is done outside of the action() call.
|
|
||||||
const toolResult = await tool.handle(this, tool.schema.inputSchema.parse(params || {}));
|
|
||||||
const { code, action, waitForNetwork, captureSnapshot, resultOverride } = toolResult;
|
|
||||||
const racingAction = action ? () => this._raceAgainstModalDialogs(action) : undefined;
|
|
||||||
|
|
||||||
if (resultOverride)
|
|
||||||
return resultOverride;
|
|
||||||
|
|
||||||
if (!this._currentTab) {
|
|
||||||
return {
|
|
||||||
content: [{
|
|
||||||
type: 'text',
|
|
||||||
text: 'No open pages available. Use the "browser_navigate" tool to navigate to a page first.',
|
|
||||||
}],
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const tab = this.currentTabOrDie();
|
|
||||||
// TODO: race against modal dialogs to resolve clicks.
|
|
||||||
let actionResult: { content?: (ImageContent | TextContent)[] } | undefined;
|
|
||||||
try {
|
|
||||||
if (waitForNetwork)
|
|
||||||
actionResult = await waitForCompletion(this, tab, async () => racingAction?.()) ?? undefined;
|
|
||||||
else
|
|
||||||
actionResult = await racingAction?.() ?? undefined;
|
|
||||||
} finally {
|
|
||||||
if (captureSnapshot && !this._javaScriptBlocked())
|
|
||||||
await tab.captureSnapshot();
|
|
||||||
}
|
|
||||||
|
|
||||||
const result: string[] = [];
|
|
||||||
result.push(`- Ran Playwright code:
|
|
||||||
\`\`\`js
|
|
||||||
${code.join('\n')}
|
|
||||||
\`\`\`
|
|
||||||
`);
|
|
||||||
|
|
||||||
if (this.modalStates().length) {
|
|
||||||
result.push(...this.modalStatesMarkdown());
|
|
||||||
return {
|
|
||||||
content: [{
|
|
||||||
type: 'text',
|
|
||||||
text: result.join('\n'),
|
|
||||||
}],
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
if (this._downloads.length) {
|
|
||||||
result.push('', '### Downloads');
|
|
||||||
for (const entry of this._downloads) {
|
|
||||||
if (entry.finished)
|
|
||||||
result.push(`- Downloaded file ${entry.download.suggestedFilename()} to ${entry.outputFile}`);
|
|
||||||
else
|
|
||||||
result.push(`- Downloading file ${entry.download.suggestedFilename()} ...`);
|
|
||||||
}
|
|
||||||
result.push('');
|
|
||||||
}
|
|
||||||
|
|
||||||
if (this.tabs().length > 1)
|
|
||||||
result.push(await this.listTabsMarkdown(), '');
|
|
||||||
|
|
||||||
if (this.tabs().length > 1)
|
|
||||||
result.push('### Current tab');
|
|
||||||
|
|
||||||
result.push(
|
|
||||||
`- Page URL: ${tab.page.url()}`,
|
|
||||||
`- Page Title: ${await tab.title()}`
|
|
||||||
);
|
|
||||||
|
|
||||||
if (captureSnapshot && tab.hasSnapshot())
|
|
||||||
result.push(tab.snapshotOrDie().text());
|
|
||||||
|
|
||||||
const content = actionResult?.content ?? [];
|
|
||||||
|
|
||||||
return {
|
|
||||||
content: [
|
|
||||||
...content,
|
|
||||||
{
|
|
||||||
type: 'text',
|
|
||||||
text: result.join('\n'),
|
|
||||||
}
|
|
||||||
],
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
async waitForTimeout(time: number) {
|
|
||||||
if (!this._currentTab || this._javaScriptBlocked()) {
|
|
||||||
await new Promise(f => setTimeout(f, time));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
await callOnPageNoTrace(this._currentTab.page, page => {
|
|
||||||
return page.evaluate(() => new Promise(f => setTimeout(f, 1000)));
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
private async _raceAgainstModalDialogs(action: () => Promise<ToolActionResult>): Promise<ToolActionResult> {
|
|
||||||
this._pendingAction = {
|
|
||||||
dialogShown: new ManualPromise(),
|
|
||||||
};
|
|
||||||
|
|
||||||
let result: ToolActionResult | undefined;
|
|
||||||
try {
|
|
||||||
await Promise.race([
|
|
||||||
action().then(r => result = r),
|
|
||||||
this._pendingAction.dialogShown,
|
|
||||||
]);
|
|
||||||
} finally {
|
|
||||||
this._pendingAction = undefined;
|
|
||||||
}
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
private _javaScriptBlocked(): boolean {
|
|
||||||
return this._modalStates.some(state => state.type === 'dialog');
|
|
||||||
}
|
|
||||||
|
|
||||||
dialogShown(tab: Tab, dialog: playwright.Dialog) {
|
|
||||||
this.setModalState({
|
|
||||||
type: 'dialog',
|
|
||||||
description: `"${dialog.type()}" dialog with message "${dialog.message()}"`,
|
|
||||||
dialog,
|
|
||||||
}, tab);
|
|
||||||
this._pendingAction?.dialogShown.resolve();
|
|
||||||
}
|
|
||||||
|
|
||||||
async downloadStarted(tab: Tab, download: playwright.Download) {
|
|
||||||
const entry = {
|
|
||||||
download,
|
|
||||||
finished: false,
|
|
||||||
outputFile: await outputFile(this.config, download.suggestedFilename())
|
|
||||||
};
|
|
||||||
this._downloads.push(entry);
|
|
||||||
await download.saveAs(entry.outputFile);
|
|
||||||
entry.finished = true;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private _onPageCreated(page: playwright.Page) {
|
private _onPageCreated(page: playwright.Page) {
|
||||||
@@ -278,7 +126,6 @@ ${code.join('\n')}
|
|||||||
}
|
}
|
||||||
|
|
||||||
private _onPageClosed(tab: Tab) {
|
private _onPageClosed(tab: Tab) {
|
||||||
this._modalStates = this._modalStates.filter(state => state.tab !== tab);
|
|
||||||
const index = this._tabs.indexOf(tab);
|
const index = this._tabs.indexOf(tab);
|
||||||
if (index === -1)
|
if (index === -1)
|
||||||
return;
|
return;
|
||||||
@@ -287,10 +134,25 @@ ${code.join('\n')}
|
|||||||
if (this._currentTab === tab)
|
if (this._currentTab === tab)
|
||||||
this._currentTab = this._tabs[Math.min(index, this._tabs.length - 1)];
|
this._currentTab = this._tabs[Math.min(index, this._tabs.length - 1)];
|
||||||
if (!this._tabs.length)
|
if (!this._tabs.length)
|
||||||
void this.close();
|
void this.closeBrowserContext();
|
||||||
}
|
}
|
||||||
|
|
||||||
async close() {
|
async closeBrowserContext() {
|
||||||
|
if (!this._closeBrowserContextPromise)
|
||||||
|
this._closeBrowserContextPromise = this._closeBrowserContextImpl().catch(logUnhandledError);
|
||||||
|
await this._closeBrowserContextPromise;
|
||||||
|
this._closeBrowserContextPromise = undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
isRunningTool() {
|
||||||
|
return this._isRunningTool;
|
||||||
|
}
|
||||||
|
|
||||||
|
setRunningTool(isRunningTool: boolean) {
|
||||||
|
this._isRunningTool = isRunningTool;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async _closeBrowserContextImpl() {
|
||||||
if (!this._browserContextPromise)
|
if (!this._browserContextPromise)
|
||||||
return;
|
return;
|
||||||
|
|
||||||
@@ -306,6 +168,12 @@ ${code.join('\n')}
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async dispose() {
|
||||||
|
this._abortController.abort('MCP context disposed');
|
||||||
|
await this.closeBrowserContext();
|
||||||
|
Context._allContexts.delete(this);
|
||||||
|
}
|
||||||
|
|
||||||
private async _setupRequestInterception(context: playwright.BrowserContext) {
|
private async _setupRequestInterception(context: playwright.BrowserContext) {
|
||||||
if (this.config.network?.allowedOrigins?.length) {
|
if (this.config.network?.allowedOrigins?.length) {
|
||||||
await context.route('**', route => route.abort('blockedbyclient'));
|
await context.route('**', route => route.abort('blockedbyclient'));
|
||||||
@@ -331,10 +199,14 @@ ${code.join('\n')}
|
|||||||
}
|
}
|
||||||
|
|
||||||
private async _setupBrowserContext(): Promise<{ browserContext: playwright.BrowserContext, close: () => Promise<void> }> {
|
private async _setupBrowserContext(): Promise<{ browserContext: playwright.BrowserContext, close: () => Promise<void> }> {
|
||||||
|
if (this._closeBrowserContextPromise)
|
||||||
|
throw new Error('Another browser context is being closed.');
|
||||||
// TODO: move to the browser context factory to make it based on isolation mode.
|
// TODO: move to the browser context factory to make it based on isolation mode.
|
||||||
const result = await this._browserContextFactory.createContext();
|
const result = await this._browserContextFactory.createContext(this._clientInfo, this._abortController.signal);
|
||||||
const { browserContext } = result;
|
const { browserContext } = result;
|
||||||
await this._setupRequestInterception(browserContext);
|
await this._setupRequestInterception(browserContext);
|
||||||
|
if (this.sessionLog)
|
||||||
|
await InputRecorder.create(this, browserContext);
|
||||||
for (const page of browserContext.pages())
|
for (const page of browserContext.pages())
|
||||||
this._onPageCreated(page);
|
this._onPageCreated(page);
|
||||||
browserContext.on('page', page => this._onPageCreated(page));
|
browserContext.on('page', page => this._onPageCreated(page));
|
||||||
@@ -349,3 +221,56 @@ ${code.join('\n')}
|
|||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export class InputRecorder {
|
||||||
|
private _context: Context;
|
||||||
|
private _browserContext: playwright.BrowserContext;
|
||||||
|
|
||||||
|
private constructor(context: Context, browserContext: playwright.BrowserContext) {
|
||||||
|
this._context = context;
|
||||||
|
this._browserContext = browserContext;
|
||||||
|
}
|
||||||
|
|
||||||
|
static async create(context: Context, browserContext: playwright.BrowserContext) {
|
||||||
|
const recorder = new InputRecorder(context, browserContext);
|
||||||
|
await recorder._initialize();
|
||||||
|
return recorder;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async _initialize() {
|
||||||
|
const sessionLog = this._context.sessionLog!;
|
||||||
|
await (this._browserContext as any)._enableRecorder({
|
||||||
|
mode: 'recording',
|
||||||
|
recorderMode: 'api',
|
||||||
|
}, {
|
||||||
|
actionAdded: (page: playwright.Page, data: actions.ActionInContext, code: string) => {
|
||||||
|
if (this._context.isRunningTool())
|
||||||
|
return;
|
||||||
|
const tab = Tab.forPage(page);
|
||||||
|
if (tab)
|
||||||
|
sessionLog.logUserAction(data.action, tab, code, false);
|
||||||
|
},
|
||||||
|
actionUpdated: (page: playwright.Page, data: actions.ActionInContext, code: string) => {
|
||||||
|
if (this._context.isRunningTool())
|
||||||
|
return;
|
||||||
|
const tab = Tab.forPage(page);
|
||||||
|
if (tab)
|
||||||
|
sessionLog.logUserAction(data.action, tab, code, true);
|
||||||
|
},
|
||||||
|
signalAdded: (page: playwright.Page, data: actions.SignalInContext) => {
|
||||||
|
if (this._context.isRunningTool())
|
||||||
|
return;
|
||||||
|
if (data.signal.name !== 'navigation')
|
||||||
|
return;
|
||||||
|
const tab = Tab.forPage(page);
|
||||||
|
const navigateAction: actions.Action = {
|
||||||
|
name: 'navigate',
|
||||||
|
url: data.signal.url,
|
||||||
|
signals: [],
|
||||||
|
};
|
||||||
|
if (tab)
|
||||||
|
sessionLog.logUserAction(navigateAction, tab, `await page.goto('${data.signal.url}');`, false);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
405
src/extension/cdpRelay.ts
Normal file
405
src/extension/cdpRelay.ts
Normal file
@@ -0,0 +1,405 @@
|
|||||||
|
/**
|
||||||
|
* Copyright (c) Microsoft Corporation.
|
||||||
|
*
|
||||||
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
* you may not use this file except in compliance with the License.
|
||||||
|
* You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing, software
|
||||||
|
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
* See the License for the specific language governing permissions and
|
||||||
|
* limitations under the License.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* WebSocket server that bridges Playwright MCP and Chrome Extension
|
||||||
|
*
|
||||||
|
* Endpoints:
|
||||||
|
* - /cdp/guid - Full CDP interface for Playwright MCP
|
||||||
|
* - /extension/guid - Extension connection for chrome.debugger forwarding
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { spawn } from 'child_process';
|
||||||
|
import http from 'http';
|
||||||
|
import debug from 'debug';
|
||||||
|
import { WebSocket, WebSocketServer } from 'ws';
|
||||||
|
import { httpAddressToString } from '../httpServer.js';
|
||||||
|
import { logUnhandledError } from '../log.js';
|
||||||
|
import { ManualPromise } from '../manualPromise.js';
|
||||||
|
import type websocket from 'ws';
|
||||||
|
import type { ClientInfo } from '../browserContextFactory.js';
|
||||||
|
|
||||||
|
// @ts-ignore
|
||||||
|
const { registry } = await import('playwright-core/lib/server/registry/index');
|
||||||
|
|
||||||
|
const debugLogger = debug('pw:mcp:relay');
|
||||||
|
|
||||||
|
type CDPCommand = {
|
||||||
|
id: number;
|
||||||
|
sessionId?: string;
|
||||||
|
method: string;
|
||||||
|
params?: any;
|
||||||
|
};
|
||||||
|
|
||||||
|
type CDPResponse = {
|
||||||
|
id?: number;
|
||||||
|
sessionId?: string;
|
||||||
|
method?: string;
|
||||||
|
params?: any;
|
||||||
|
result?: any;
|
||||||
|
error?: { code?: number; message: string };
|
||||||
|
};
|
||||||
|
|
||||||
|
export class CDPRelayServer {
|
||||||
|
private _wsHost: string;
|
||||||
|
private _browserChannel: string;
|
||||||
|
private _userDataDir?: string;
|
||||||
|
private _cdpPath: string;
|
||||||
|
private _extensionPath: string;
|
||||||
|
private _wss: WebSocketServer;
|
||||||
|
private _playwrightConnection: WebSocket | null = null;
|
||||||
|
private _extensionConnection: ExtensionConnection | null = null;
|
||||||
|
private _connectedTabInfo: {
|
||||||
|
targetInfo: any;
|
||||||
|
// Page sessionId that should be used by this connection.
|
||||||
|
sessionId: string;
|
||||||
|
} | undefined;
|
||||||
|
private _nextSessionId: number = 1;
|
||||||
|
private _extensionConnectionPromise!: ManualPromise<void>;
|
||||||
|
|
||||||
|
constructor(server: http.Server, browserChannel: string, userDataDir?: string) {
|
||||||
|
this._wsHost = httpAddressToString(server.address()).replace(/^http/, 'ws');
|
||||||
|
this._browserChannel = browserChannel;
|
||||||
|
this._userDataDir = userDataDir;
|
||||||
|
|
||||||
|
const uuid = crypto.randomUUID();
|
||||||
|
this._cdpPath = `/cdp/${uuid}`;
|
||||||
|
this._extensionPath = `/extension/${uuid}`;
|
||||||
|
|
||||||
|
this._resetExtensionConnection();
|
||||||
|
this._wss = new WebSocketServer({ server });
|
||||||
|
this._wss.on('connection', this._onConnection.bind(this));
|
||||||
|
}
|
||||||
|
|
||||||
|
cdpEndpoint() {
|
||||||
|
return `${this._wsHost}${this._cdpPath}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
extensionEndpoint() {
|
||||||
|
return `${this._wsHost}${this._extensionPath}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async ensureExtensionConnectionForMCPContext(clientInfo: ClientInfo, abortSignal: AbortSignal) {
|
||||||
|
debugLogger('Ensuring extension connection for MCP context');
|
||||||
|
if (this._extensionConnection)
|
||||||
|
return;
|
||||||
|
this._connectBrowser(clientInfo);
|
||||||
|
debugLogger('Waiting for incoming extension connection');
|
||||||
|
await Promise.race([
|
||||||
|
this._extensionConnectionPromise,
|
||||||
|
new Promise((_, reject) => abortSignal.addEventListener('abort', reject))
|
||||||
|
]);
|
||||||
|
debugLogger('Extension connection established');
|
||||||
|
}
|
||||||
|
|
||||||
|
private _connectBrowser(clientInfo: ClientInfo) {
|
||||||
|
const mcpRelayEndpoint = `${this._wsHost}${this._extensionPath}`;
|
||||||
|
// Need to specify "key" in the manifest.json to make the id stable when loading from file.
|
||||||
|
const url = new URL('chrome-extension://jakfalbnbhgkpmoaakfflhflbfpkailf/connect.html');
|
||||||
|
url.searchParams.set('mcpRelayUrl', mcpRelayEndpoint);
|
||||||
|
url.searchParams.set('client', JSON.stringify(clientInfo));
|
||||||
|
const href = url.toString();
|
||||||
|
const executableInfo = registry.findExecutable(this._browserChannel);
|
||||||
|
if (!executableInfo)
|
||||||
|
throw new Error(`Unsupported channel: "${this._browserChannel}"`);
|
||||||
|
const executablePath = executableInfo.executablePath();
|
||||||
|
if (!executablePath)
|
||||||
|
throw new Error(`"${this._browserChannel}" executable not found. Make sure it is installed at a standard location.`);
|
||||||
|
|
||||||
|
const args: string[] = [];
|
||||||
|
if (this._userDataDir)
|
||||||
|
args.push(`--user-data-dir=${this._userDataDir}`);
|
||||||
|
args.push(href);
|
||||||
|
|
||||||
|
spawn(executablePath, args, {
|
||||||
|
windowsHide: true,
|
||||||
|
detached: true,
|
||||||
|
shell: false,
|
||||||
|
stdio: 'ignore',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
stop(): void {
|
||||||
|
this.closeConnections('Server stopped');
|
||||||
|
this._wss.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
closeConnections(reason: string) {
|
||||||
|
this._closePlaywrightConnection(reason);
|
||||||
|
this._closeExtensionConnection(reason);
|
||||||
|
}
|
||||||
|
|
||||||
|
private _onConnection(ws: WebSocket, request: http.IncomingMessage): void {
|
||||||
|
const url = new URL(`http://localhost${request.url}`);
|
||||||
|
debugLogger(`New connection to ${url.pathname}`);
|
||||||
|
if (url.pathname === this._cdpPath) {
|
||||||
|
this._handlePlaywrightConnection(ws);
|
||||||
|
} else if (url.pathname === this._extensionPath) {
|
||||||
|
this._handleExtensionConnection(ws);
|
||||||
|
} else {
|
||||||
|
debugLogger(`Invalid path: ${url.pathname}`);
|
||||||
|
ws.close(4004, 'Invalid path');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private _handlePlaywrightConnection(ws: WebSocket): void {
|
||||||
|
if (this._playwrightConnection) {
|
||||||
|
debugLogger('Rejecting second Playwright connection');
|
||||||
|
ws.close(1000, 'Another CDP client already connected');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this._playwrightConnection = ws;
|
||||||
|
ws.on('message', async data => {
|
||||||
|
try {
|
||||||
|
const message = JSON.parse(data.toString());
|
||||||
|
await this._handlePlaywrightMessage(message);
|
||||||
|
} catch (error: any) {
|
||||||
|
debugLogger(`Error while handling Playwright message\n${data.toString()}\n`, error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
ws.on('close', () => {
|
||||||
|
if (this._playwrightConnection !== ws)
|
||||||
|
return;
|
||||||
|
this._playwrightConnection = null;
|
||||||
|
this._closeExtensionConnection('Playwright client disconnected');
|
||||||
|
debugLogger('Playwright WebSocket closed');
|
||||||
|
});
|
||||||
|
ws.on('error', error => {
|
||||||
|
debugLogger('Playwright WebSocket error:', error);
|
||||||
|
});
|
||||||
|
debugLogger('Playwright MCP connected');
|
||||||
|
}
|
||||||
|
|
||||||
|
private _closeExtensionConnection(reason: string) {
|
||||||
|
this._extensionConnection?.close(reason);
|
||||||
|
this._extensionConnectionPromise.reject(new Error(reason));
|
||||||
|
this._resetExtensionConnection();
|
||||||
|
}
|
||||||
|
|
||||||
|
private _resetExtensionConnection() {
|
||||||
|
this._connectedTabInfo = undefined;
|
||||||
|
this._extensionConnection = null;
|
||||||
|
this._extensionConnectionPromise = new ManualPromise();
|
||||||
|
void this._extensionConnectionPromise.catch(logUnhandledError);
|
||||||
|
}
|
||||||
|
|
||||||
|
private _closePlaywrightConnection(reason: string) {
|
||||||
|
if (this._playwrightConnection?.readyState === WebSocket.OPEN)
|
||||||
|
this._playwrightConnection.close(1000, reason);
|
||||||
|
this._playwrightConnection = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private _handleExtensionConnection(ws: WebSocket): void {
|
||||||
|
if (this._extensionConnection) {
|
||||||
|
ws.close(1000, 'Another extension connection already established');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this._extensionConnection = new ExtensionConnection(ws);
|
||||||
|
this._extensionConnection.onclose = (c, reason) => {
|
||||||
|
debugLogger('Extension WebSocket closed:', reason, c === this._extensionConnection);
|
||||||
|
if (this._extensionConnection !== c)
|
||||||
|
return;
|
||||||
|
this._resetExtensionConnection();
|
||||||
|
this._closePlaywrightConnection(`Extension disconnected: ${reason}`);
|
||||||
|
};
|
||||||
|
this._extensionConnection.onmessage = this._handleExtensionMessage.bind(this);
|
||||||
|
this._extensionConnectionPromise.resolve();
|
||||||
|
}
|
||||||
|
|
||||||
|
private _handleExtensionMessage(method: string, params: any) {
|
||||||
|
switch (method) {
|
||||||
|
case 'forwardCDPEvent':
|
||||||
|
const sessionId = params.sessionId || this._connectedTabInfo?.sessionId;
|
||||||
|
this._sendToPlaywright({
|
||||||
|
sessionId,
|
||||||
|
method: params.method,
|
||||||
|
params: params.params
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
case 'detachedFromTab':
|
||||||
|
debugLogger('← Debugger detached from tab:', params);
|
||||||
|
this._connectedTabInfo = undefined;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async _handlePlaywrightMessage(message: CDPCommand): Promise<void> {
|
||||||
|
debugLogger('← Playwright:', `${message.method} (id=${message.id})`);
|
||||||
|
const { id, sessionId, method, params } = message;
|
||||||
|
try {
|
||||||
|
const result = await this._handleCDPCommand(method, params, sessionId);
|
||||||
|
this._sendToPlaywright({ id, sessionId, result });
|
||||||
|
} catch (e) {
|
||||||
|
debugLogger('Error in the extension:', e);
|
||||||
|
this._sendToPlaywright({
|
||||||
|
id,
|
||||||
|
sessionId,
|
||||||
|
error: { message: (e as Error).message }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async _handleCDPCommand(method: string, params: any, sessionId: string | undefined): Promise<any> {
|
||||||
|
switch (method) {
|
||||||
|
case 'Browser.getVersion': {
|
||||||
|
return {
|
||||||
|
protocolVersion: '1.3',
|
||||||
|
product: 'Chrome/Extension-Bridge',
|
||||||
|
userAgent: 'CDP-Bridge-Server/1.0.0',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
case 'Browser.setDownloadBehavior': {
|
||||||
|
return { };
|
||||||
|
}
|
||||||
|
case 'Target.setAutoAttach': {
|
||||||
|
// Forward child session handling.
|
||||||
|
if (sessionId)
|
||||||
|
break;
|
||||||
|
// Simulate auto-attach behavior with real target info
|
||||||
|
const { targetInfo } = await this._extensionConnection!.send('attachToTab');
|
||||||
|
this._connectedTabInfo = {
|
||||||
|
targetInfo,
|
||||||
|
sessionId: `pw-tab-${this._nextSessionId++}`,
|
||||||
|
};
|
||||||
|
debugLogger('Simulating auto-attach');
|
||||||
|
this._sendToPlaywright({
|
||||||
|
method: 'Target.attachedToTarget',
|
||||||
|
params: {
|
||||||
|
sessionId: this._connectedTabInfo.sessionId,
|
||||||
|
targetInfo: {
|
||||||
|
...this._connectedTabInfo.targetInfo,
|
||||||
|
attached: true,
|
||||||
|
},
|
||||||
|
waitingForDebugger: false
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return { };
|
||||||
|
}
|
||||||
|
case 'Target.getTargetInfo': {
|
||||||
|
return this._connectedTabInfo?.targetInfo;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return await this._forwardToExtension(method, params, sessionId);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async _forwardToExtension(method: string, params: any, sessionId: string | undefined): Promise<any> {
|
||||||
|
if (!this._extensionConnection)
|
||||||
|
throw new Error('Extension not connected');
|
||||||
|
// Top level sessionId is only passed between the relay and the client.
|
||||||
|
if (this._connectedTabInfo?.sessionId === sessionId)
|
||||||
|
sessionId = undefined;
|
||||||
|
return await this._extensionConnection.send('forwardCDPCommand', { sessionId, method, params });
|
||||||
|
}
|
||||||
|
|
||||||
|
private _sendToPlaywright(message: CDPResponse): void {
|
||||||
|
debugLogger('→ Playwright:', `${message.method ?? `response(id=${message.id})`}`);
|
||||||
|
this._playwrightConnection?.send(JSON.stringify(message));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type ExtensionResponse = {
|
||||||
|
id?: number;
|
||||||
|
method?: string;
|
||||||
|
params?: any;
|
||||||
|
result?: any;
|
||||||
|
error?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
class ExtensionConnection {
|
||||||
|
private readonly _ws: WebSocket;
|
||||||
|
private readonly _callbacks = new Map<number, { resolve: (o: any) => void, reject: (e: Error) => void, error: Error }>();
|
||||||
|
private _lastId = 0;
|
||||||
|
|
||||||
|
onmessage?: (method: string, params: any) => void;
|
||||||
|
onclose?: (self: ExtensionConnection, reason: string) => void;
|
||||||
|
|
||||||
|
constructor(ws: WebSocket) {
|
||||||
|
this._ws = ws;
|
||||||
|
this._ws.on('message', this._onMessage.bind(this));
|
||||||
|
this._ws.on('close', this._onClose.bind(this));
|
||||||
|
this._ws.on('error', this._onError.bind(this));
|
||||||
|
}
|
||||||
|
|
||||||
|
async send(method: string, params?: any, sessionId?: string): Promise<any> {
|
||||||
|
if (this._ws.readyState !== WebSocket.OPEN)
|
||||||
|
throw new Error(`Unexpected WebSocket state: ${this._ws.readyState}`);
|
||||||
|
const id = ++this._lastId;
|
||||||
|
this._ws.send(JSON.stringify({ id, method, params, sessionId }));
|
||||||
|
const error = new Error(`Protocol error: ${method}`);
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
this._callbacks.set(id, { resolve, reject, error });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
close(message: string) {
|
||||||
|
debugLogger('closing extension connection:', message);
|
||||||
|
if (this._ws.readyState === WebSocket.OPEN)
|
||||||
|
this._ws.close(1000, message);
|
||||||
|
}
|
||||||
|
|
||||||
|
private _onMessage(event: websocket.RawData) {
|
||||||
|
const eventData = event.toString();
|
||||||
|
let parsedJson;
|
||||||
|
try {
|
||||||
|
parsedJson = JSON.parse(eventData);
|
||||||
|
} catch (e: any) {
|
||||||
|
debugLogger(`<closing ws> Closing websocket due to malformed JSON. eventData=${eventData} e=${e?.message}`);
|
||||||
|
this._ws.close();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
this._handleParsedMessage(parsedJson);
|
||||||
|
} catch (e: any) {
|
||||||
|
debugLogger(`<closing ws> Closing websocket due to failed onmessage callback. eventData=${eventData} e=${e?.message}`);
|
||||||
|
this._ws.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private _handleParsedMessage(object: ExtensionResponse) {
|
||||||
|
if (object.id && this._callbacks.has(object.id)) {
|
||||||
|
const callback = this._callbacks.get(object.id)!;
|
||||||
|
this._callbacks.delete(object.id);
|
||||||
|
if (object.error) {
|
||||||
|
const error = callback.error;
|
||||||
|
error.message = object.error;
|
||||||
|
callback.reject(error);
|
||||||
|
} else {
|
||||||
|
callback.resolve(object.result);
|
||||||
|
}
|
||||||
|
} else if (object.id) {
|
||||||
|
debugLogger('← Extension: unexpected response', object);
|
||||||
|
} else {
|
||||||
|
this.onmessage?.(object.method!, object.params);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private _onClose(event: websocket.CloseEvent) {
|
||||||
|
debugLogger(`<ws closed> code=${event.code} reason=${event.reason}`);
|
||||||
|
this._dispose();
|
||||||
|
this.onclose?.(this, event.reason);
|
||||||
|
}
|
||||||
|
|
||||||
|
private _onError(event: websocket.ErrorEvent) {
|
||||||
|
debugLogger(`<ws error> message=${event.message} type=${event.type} target=${event.target}`);
|
||||||
|
this._dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
private _dispose() {
|
||||||
|
for (const callback of this._callbacks.values())
|
||||||
|
callback.reject(new Error('WebSocket closed'));
|
||||||
|
this._callbacks.clear();
|
||||||
|
}
|
||||||
|
}
|
||||||
66
src/extension/extensionContextFactory.ts
Normal file
66
src/extension/extensionContextFactory.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 debug from 'debug';
|
||||||
|
import * as playwright from 'playwright';
|
||||||
|
import { startHttpServer } from '../httpServer.js';
|
||||||
|
import { CDPRelayServer } from './cdpRelay.js';
|
||||||
|
|
||||||
|
import type { BrowserContextFactory, ClientInfo } from '../browserContextFactory.js';
|
||||||
|
|
||||||
|
const debugLogger = debug('pw:mcp:relay');
|
||||||
|
|
||||||
|
export class ExtensionContextFactory implements BrowserContextFactory {
|
||||||
|
name = 'extension';
|
||||||
|
description = 'Connect to a browser using the Playwright MCP extension';
|
||||||
|
|
||||||
|
private _browserChannel: string;
|
||||||
|
private _userDataDir?: string;
|
||||||
|
|
||||||
|
constructor(browserChannel: string, userDataDir: string | undefined) {
|
||||||
|
this._browserChannel = browserChannel;
|
||||||
|
this._userDataDir = userDataDir;
|
||||||
|
}
|
||||||
|
|
||||||
|
async createContext(clientInfo: ClientInfo, abortSignal: AbortSignal): Promise<{ browserContext: playwright.BrowserContext, close: () => Promise<void> }> {
|
||||||
|
const browser = await this._obtainBrowser(clientInfo, abortSignal);
|
||||||
|
return {
|
||||||
|
browserContext: browser.contexts()[0],
|
||||||
|
close: async () => {
|
||||||
|
debugLogger('close() called for browser context');
|
||||||
|
await browser.close();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private async _obtainBrowser(clientInfo: ClientInfo, abortSignal: AbortSignal): Promise<playwright.Browser> {
|
||||||
|
const relay = await this._startRelay(abortSignal);
|
||||||
|
await relay.ensureExtensionConnectionForMCPContext(clientInfo, abortSignal);
|
||||||
|
return await playwright.chromium.connectOverCDP(relay.cdpEndpoint());
|
||||||
|
}
|
||||||
|
|
||||||
|
private async _startRelay(abortSignal: AbortSignal) {
|
||||||
|
const httpServer = await startHttpServer({});
|
||||||
|
if (abortSignal.aborted) {
|
||||||
|
httpServer.close();
|
||||||
|
throw new Error(abortSignal.reason);
|
||||||
|
}
|
||||||
|
const cdpRelayServer = new CDPRelayServer(httpServer, this._browserChannel, this._userDataDir);
|
||||||
|
abortSignal.addEventListener('abort', () => cdpRelayServer.stop());
|
||||||
|
debugLogger(`CDP relay server started, extension endpoint: ${cdpRelayServer.extensionEndpoint()}.`);
|
||||||
|
return cdpRelayServer;
|
||||||
|
}
|
||||||
|
}
|
||||||
31
src/extension/main.ts
Normal file
31
src/extension/main.ts
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
/**
|
||||||
|
* Copyright (c) Microsoft Corporation.
|
||||||
|
*
|
||||||
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
* you may not use this file except in compliance with the License.
|
||||||
|
* You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing, software
|
||||||
|
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
* See the License for the specific language governing permissions and
|
||||||
|
* limitations under the License.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { ExtensionContextFactory } from './extensionContextFactory.js';
|
||||||
|
import { BrowserServerBackend } from '../browserServerBackend.js';
|
||||||
|
import * as mcpTransport from '../mcp/transport.js';
|
||||||
|
|
||||||
|
import type { FullConfig } from '../config.js';
|
||||||
|
|
||||||
|
export async function runWithExtension(config: FullConfig) {
|
||||||
|
const contextFactory = new ExtensionContextFactory(config.browser.launchOptions.channel || 'chrome', config.browser.userDataDir);
|
||||||
|
const serverBackendFactory = () => new BrowserServerBackend(config, [contextFactory]);
|
||||||
|
await mcpTransport.start(serverBackendFactory, config.server);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createExtensionContextFactory(config: FullConfig) {
|
||||||
|
return new ExtensionContextFactory(config.browser.launchOptions.channel || 'chrome', config.browser.userDataDir);
|
||||||
|
}
|
||||||
@@ -14,219 +14,31 @@
|
|||||||
* limitations under the License.
|
* limitations under the License.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import fs from 'fs';
|
import assert from 'assert';
|
||||||
import path from 'path';
|
|
||||||
import http from 'http';
|
import http from 'http';
|
||||||
import net from 'net';
|
|
||||||
|
|
||||||
import mime from 'mime';
|
import type * as net from 'net';
|
||||||
|
|
||||||
import { ManualPromise } from './manualPromise.js';
|
export async function startHttpServer(config: { host?: string, port?: number }): Promise<http.Server> {
|
||||||
|
const { host, port } = config;
|
||||||
|
const httpServer = http.createServer();
|
||||||
export type ServerRouteHandler = (request: http.IncomingMessage, response: http.ServerResponse) => void;
|
await new Promise<void>((resolve, reject) => {
|
||||||
|
httpServer.on('error', reject);
|
||||||
export type Transport = {
|
httpServer.listen(port, host, () => {
|
||||||
sendEvent?: (method: string, params: any) => void;
|
resolve();
|
||||||
close?: () => void;
|
httpServer.removeListener('error', reject);
|
||||||
onconnect: () => void;
|
|
||||||
dispatch: (method: string, params: any) => Promise<any>;
|
|
||||||
onclose: () => void;
|
|
||||||
};
|
|
||||||
|
|
||||||
export class HttpServer {
|
|
||||||
private _server: http.Server;
|
|
||||||
private _urlPrefixPrecise: string = '';
|
|
||||||
private _urlPrefixHumanReadable: string = '';
|
|
||||||
private _port: number = 0;
|
|
||||||
private _routes: { prefix?: string, exact?: string, handler: ServerRouteHandler }[] = [];
|
|
||||||
|
|
||||||
constructor() {
|
|
||||||
this._server = http.createServer(this._onRequest.bind(this));
|
|
||||||
decorateServer(this._server);
|
|
||||||
}
|
|
||||||
|
|
||||||
server() {
|
|
||||||
return this._server;
|
|
||||||
}
|
|
||||||
|
|
||||||
routePrefix(prefix: string, handler: ServerRouteHandler) {
|
|
||||||
this._routes.push({ prefix, handler });
|
|
||||||
}
|
|
||||||
|
|
||||||
routePath(path: string, handler: ServerRouteHandler) {
|
|
||||||
this._routes.push({ exact: path, handler });
|
|
||||||
}
|
|
||||||
|
|
||||||
port(): number {
|
|
||||||
return this._port;
|
|
||||||
}
|
|
||||||
|
|
||||||
private async _tryStart(port: number | undefined, host: string) {
|
|
||||||
const errorPromise = new ManualPromise();
|
|
||||||
const errorListener = (error: Error) => errorPromise.reject(error);
|
|
||||||
this._server.on('error', errorListener);
|
|
||||||
|
|
||||||
try {
|
|
||||||
this._server.listen(port, host);
|
|
||||||
await Promise.race([
|
|
||||||
new Promise(cb => this._server!.once('listening', cb)),
|
|
||||||
errorPromise,
|
|
||||||
]);
|
|
||||||
} finally {
|
|
||||||
this._server.removeListener('error', errorListener);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async start(options: { port?: number, preferredPort?: number, host?: string } = {}): Promise<void> {
|
|
||||||
const host = options.host || 'localhost';
|
|
||||||
if (options.preferredPort) {
|
|
||||||
try {
|
|
||||||
await this._tryStart(options.preferredPort, host);
|
|
||||||
} catch (e: any) {
|
|
||||||
if (!e || !e.message || !e.message.includes('EADDRINUSE'))
|
|
||||||
throw e;
|
|
||||||
await this._tryStart(undefined, host);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
await this._tryStart(options.port, host);
|
|
||||||
}
|
|
||||||
|
|
||||||
const address = this._server.address();
|
|
||||||
if (typeof address === 'string') {
|
|
||||||
this._urlPrefixPrecise = address;
|
|
||||||
this._urlPrefixHumanReadable = address;
|
|
||||||
} else {
|
|
||||||
this._port = address!.port;
|
|
||||||
const resolvedHost = address!.family === 'IPv4' ? address!.address : `[${address!.address}]`;
|
|
||||||
this._urlPrefixPrecise = `http://${resolvedHost}:${address!.port}`;
|
|
||||||
this._urlPrefixHumanReadable = `http://${host}:${address!.port}`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async stop() {
|
|
||||||
await new Promise(cb => this._server!.close(cb));
|
|
||||||
}
|
|
||||||
|
|
||||||
urlPrefix(purpose: 'human-readable' | 'precise'): string {
|
|
||||||
return purpose === 'human-readable' ? this._urlPrefixHumanReadable : this._urlPrefixPrecise;
|
|
||||||
}
|
|
||||||
|
|
||||||
serveFile(request: http.IncomingMessage, response: http.ServerResponse, absoluteFilePath: string, headers?: { [name: string]: string }): boolean {
|
|
||||||
try {
|
|
||||||
for (const [name, value] of Object.entries(headers || {}))
|
|
||||||
response.setHeader(name, value);
|
|
||||||
if (request.headers.range)
|
|
||||||
this._serveRangeFile(request, response, absoluteFilePath);
|
|
||||||
else
|
|
||||||
this._serveFile(response, absoluteFilePath);
|
|
||||||
return true;
|
|
||||||
} catch (e) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
_serveFile(response: http.ServerResponse, absoluteFilePath: string) {
|
|
||||||
const content = fs.readFileSync(absoluteFilePath);
|
|
||||||
response.statusCode = 200;
|
|
||||||
const contentType = mime.getType(path.extname(absoluteFilePath)) || 'application/octet-stream';
|
|
||||||
response.setHeader('Content-Type', contentType);
|
|
||||||
response.setHeader('Content-Length', content.byteLength);
|
|
||||||
response.end(content);
|
|
||||||
}
|
|
||||||
|
|
||||||
_serveRangeFile(request: http.IncomingMessage, response: http.ServerResponse, absoluteFilePath: string) {
|
|
||||||
const range = request.headers.range;
|
|
||||||
if (!range || !range.startsWith('bytes=') || range.includes(', ') || [...range].filter(char => char === '-').length !== 1) {
|
|
||||||
response.statusCode = 400;
|
|
||||||
return response.end('Bad request');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Parse the range header: https://datatracker.ietf.org/doc/html/rfc7233#section-2.1
|
|
||||||
const [startStr, endStr] = range.replace(/bytes=/, '').split('-');
|
|
||||||
|
|
||||||
// Both start and end (when passing to fs.createReadStream) and the range header are inclusive and start counting at 0.
|
|
||||||
let start: number;
|
|
||||||
let end: number;
|
|
||||||
const size = fs.statSync(absoluteFilePath).size;
|
|
||||||
if (startStr !== '' && endStr === '') {
|
|
||||||
// No end specified: use the whole file
|
|
||||||
start = +startStr;
|
|
||||||
end = size - 1;
|
|
||||||
} else if (startStr === '' && endStr !== '') {
|
|
||||||
// No start specified: calculate start manually
|
|
||||||
start = size - +endStr;
|
|
||||||
end = size - 1;
|
|
||||||
} else {
|
|
||||||
start = +startStr;
|
|
||||||
end = +endStr;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Handle unavailable range request
|
|
||||||
if (Number.isNaN(start) || Number.isNaN(end) || start >= size || end >= size || start > end) {
|
|
||||||
// Return the 416 Range Not Satisfiable: https://datatracker.ietf.org/doc/html/rfc7233#section-4.4
|
|
||||||
response.writeHead(416, {
|
|
||||||
'Content-Range': `bytes */${size}`
|
|
||||||
});
|
|
||||||
return response.end();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Sending Partial Content: https://datatracker.ietf.org/doc/html/rfc7233#section-4.1
|
|
||||||
response.writeHead(206, {
|
|
||||||
'Content-Range': `bytes ${start}-${end}/${size}`,
|
|
||||||
'Accept-Ranges': 'bytes',
|
|
||||||
'Content-Length': end - start + 1,
|
|
||||||
'Content-Type': mime.getType(path.extname(absoluteFilePath))!,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const readable = fs.createReadStream(absoluteFilePath, { start, end });
|
|
||||||
readable.pipe(response);
|
|
||||||
}
|
|
||||||
|
|
||||||
private _onRequest(request: http.IncomingMessage, response: http.ServerResponse) {
|
|
||||||
if (request.method === 'OPTIONS') {
|
|
||||||
response.writeHead(200);
|
|
||||||
response.end();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
request.on('error', () => response.end());
|
|
||||||
try {
|
|
||||||
if (!request.url) {
|
|
||||||
response.end();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const url = new URL('http://localhost' + request.url);
|
|
||||||
for (const route of this._routes) {
|
|
||||||
if (route.exact && url.pathname === route.exact) {
|
|
||||||
route.handler(request, response);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (route.prefix && url.pathname.startsWith(route.prefix)) {
|
|
||||||
route.handler(request, response);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
response.statusCode = 404;
|
|
||||||
response.end();
|
|
||||||
} catch (e) {
|
|
||||||
response.end();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function decorateServer(server: net.Server) {
|
|
||||||
const sockets = new Set<net.Socket>();
|
|
||||||
server.on('connection', socket => {
|
|
||||||
sockets.add(socket);
|
|
||||||
socket.once('close', () => sockets.delete(socket));
|
|
||||||
});
|
});
|
||||||
|
return httpServer;
|
||||||
const close = server.close;
|
}
|
||||||
server.close = (callback?: (err?: Error) => void) => {
|
|
||||||
for (const socket of sockets)
|
export function httpAddressToString(address: string | net.AddressInfo | null): string {
|
||||||
socket.destroy();
|
assert(address, 'Could not bind server socket');
|
||||||
sockets.clear();
|
if (typeof address === 'string')
|
||||||
return close.call(server, callback);
|
return address;
|
||||||
};
|
const resolvedPort = address.port;
|
||||||
|
let resolvedHost = address.family === 'IPv4' ? address.address : `[${address.address}]`;
|
||||||
|
if (resolvedHost === '0.0.0.0' || resolvedHost === '[::]')
|
||||||
|
resolvedHost = 'localhost';
|
||||||
|
return `http://${resolvedHost}:${resolvedPort}`;
|
||||||
}
|
}
|
||||||
|
|||||||
14
src/index.ts
14
src/index.ts
@@ -14,22 +14,26 @@
|
|||||||
* limitations under the License.
|
* limitations under the License.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { createConnection as createConnectionImpl } from './connection.js';
|
import { BrowserServerBackend } from './browserServerBackend.js';
|
||||||
import type { Connection } from '../index.js';
|
|
||||||
import { resolveConfig } from './config.js';
|
import { resolveConfig } from './config.js';
|
||||||
import { contextFactory } from './browserContextFactory.js';
|
import { contextFactory } from './browserContextFactory.js';
|
||||||
|
import * as mcpServer from './mcp/server.js';
|
||||||
|
|
||||||
import type { Config } from '../config.js';
|
import type { Config } from '../config.js';
|
||||||
import type { BrowserContext } from 'playwright';
|
import type { BrowserContext } from 'playwright';
|
||||||
import type { BrowserContextFactory } from './browserContextFactory.js';
|
import type { BrowserContextFactory } from './browserContextFactory.js';
|
||||||
|
import type { Server } from '@modelcontextprotocol/sdk/server/index.js';
|
||||||
|
|
||||||
export async function createConnection(userConfig: Config = {}, contextGetter?: () => Promise<BrowserContext>): Promise<Connection> {
|
export async function createConnection(userConfig: Config = {}, contextGetter?: () => Promise<BrowserContext>): Promise<Server> {
|
||||||
const config = await resolveConfig(userConfig);
|
const config = await resolveConfig(userConfig);
|
||||||
const factory = contextGetter ? new SimpleBrowserContextFactory(contextGetter) : contextFactory(config.browser);
|
const factory = contextGetter ? new SimpleBrowserContextFactory(contextGetter) : contextFactory(config);
|
||||||
return createConnectionImpl(config, factory);
|
return mcpServer.createServer(new BrowserServerBackend(config, [factory]), false);
|
||||||
}
|
}
|
||||||
|
|
||||||
class SimpleBrowserContextFactory implements BrowserContextFactory {
|
class SimpleBrowserContextFactory implements BrowserContextFactory {
|
||||||
|
name = 'custom';
|
||||||
|
description = 'Connect to a browser using a custom context getter';
|
||||||
|
|
||||||
private readonly _contextGetter: () => Promise<BrowserContext>;
|
private readonly _contextGetter: () => Promise<BrowserContext>;
|
||||||
|
|
||||||
constructor(contextGetter: () => Promise<BrowserContext>) {
|
constructor(contextGetter: () => Promise<BrowserContext>) {
|
||||||
|
|||||||
@@ -14,23 +14,12 @@
|
|||||||
* limitations under the License.
|
* limitations under the License.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import type { Context } from '../context.js';
|
import debug from 'debug';
|
||||||
|
|
||||||
export type ResourceSchema = {
|
const errorsDebug = debug('pw:mcp:errors');
|
||||||
uri: string;
|
|
||||||
name: string;
|
|
||||||
description?: string;
|
|
||||||
mimeType?: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type ResourceResult = {
|
export function logUnhandledError(error: unknown) {
|
||||||
uri: string;
|
errorsDebug(error);
|
||||||
mimeType?: string;
|
}
|
||||||
text?: string;
|
|
||||||
blob?: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type Resource = {
|
export const testDebug = debug('pw:mcp:test');
|
||||||
schema: ResourceSchema;
|
|
||||||
read: (context: Context, uri: string) => Promise<ResourceResult[]>;
|
|
||||||
};
|
|
||||||
108
src/loop/loop.ts
Normal file
108
src/loop/loop.ts
Normal file
@@ -0,0 +1,108 @@
|
|||||||
|
/**
|
||||||
|
* Copyright (c) Microsoft Corporation.
|
||||||
|
*
|
||||||
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
* you may not use this file except in compliance with the License.
|
||||||
|
* You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing, software
|
||||||
|
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
* See the License for the specific language governing permissions and
|
||||||
|
* limitations under the License.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import debug from 'debug';
|
||||||
|
import type { Tool, ImageContent, TextContent } from '@modelcontextprotocol/sdk/types.js';
|
||||||
|
import type { Client } from '@modelcontextprotocol/sdk/client/index.js';
|
||||||
|
|
||||||
|
export type LLMToolCall = {
|
||||||
|
name: string;
|
||||||
|
arguments: any;
|
||||||
|
id: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type LLMTool = {
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
inputSchema: any;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type LLMMessage =
|
||||||
|
| { role: 'user'; content: string }
|
||||||
|
| { role: 'assistant'; content: string; toolCalls?: LLMToolCall[] }
|
||||||
|
| { role: 'tool'; toolCallId: string; content: string; isError?: boolean };
|
||||||
|
|
||||||
|
export type LLMConversation = {
|
||||||
|
messages: LLMMessage[];
|
||||||
|
tools: LLMTool[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export interface LLMDelegate {
|
||||||
|
createConversation(task: string, tools: Tool[], oneShot: boolean): LLMConversation;
|
||||||
|
makeApiCall(conversation: LLMConversation): Promise<LLMToolCall[]>;
|
||||||
|
addToolResults(conversation: LLMConversation, results: Array<{ toolCallId: string; content: string; isError?: boolean }>): void;
|
||||||
|
checkDoneToolCall(toolCall: LLMToolCall): string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function runTask(delegate: LLMDelegate, client: Client, task: string, oneShot: boolean = false): Promise<LLMMessage[]> {
|
||||||
|
const { tools } = await client.listTools();
|
||||||
|
const taskContent = oneShot ? `Perform following task: ${task}.` : `Perform following task: ${task}. Once the task is complete, call the "done" tool.`;
|
||||||
|
const conversation = delegate.createConversation(taskContent, tools, oneShot);
|
||||||
|
|
||||||
|
for (let iteration = 0; iteration < 5; ++iteration) {
|
||||||
|
debug('history')('Making API call for iteration', iteration);
|
||||||
|
const toolCalls = await delegate.makeApiCall(conversation);
|
||||||
|
if (toolCalls.length === 0)
|
||||||
|
throw new Error('Call the "done" tool when the task is complete.');
|
||||||
|
|
||||||
|
const toolResults: Array<{ toolCallId: string; content: string; isError?: boolean }> = [];
|
||||||
|
for (const toolCall of toolCalls) {
|
||||||
|
const doneResult = delegate.checkDoneToolCall(toolCall);
|
||||||
|
if (doneResult !== null)
|
||||||
|
return conversation.messages;
|
||||||
|
|
||||||
|
const { name, arguments: args, id } = toolCall;
|
||||||
|
try {
|
||||||
|
debug('tool')(name, args);
|
||||||
|
const response = await client.callTool({
|
||||||
|
name,
|
||||||
|
arguments: args,
|
||||||
|
});
|
||||||
|
const responseContent = (response.content || []) as (TextContent | ImageContent)[];
|
||||||
|
debug('tool')(responseContent);
|
||||||
|
const text = responseContent.filter(part => part.type === 'text').map(part => part.text).join('\n');
|
||||||
|
|
||||||
|
toolResults.push({
|
||||||
|
toolCallId: id,
|
||||||
|
content: text,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
debug('tool')(error);
|
||||||
|
toolResults.push({
|
||||||
|
toolCallId: id,
|
||||||
|
content: `Error while executing tool "${name}": ${error instanceof Error ? error.message : String(error)}\n\nPlease try to recover and complete the task.`,
|
||||||
|
isError: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Skip remaining tool calls for this iteration
|
||||||
|
for (const remainingToolCall of toolCalls.slice(toolCalls.indexOf(toolCall) + 1)) {
|
||||||
|
toolResults.push({
|
||||||
|
toolCallId: remainingToolCall.id,
|
||||||
|
content: `This tool call is skipped due to previous error.`,
|
||||||
|
isError: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
delegate.addToolResults(conversation, toolResults);
|
||||||
|
if (oneShot)
|
||||||
|
return conversation.messages;
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error('Failed to perform step, max attempts reached');
|
||||||
|
}
|
||||||
177
src/loop/loopClaude.ts
Normal file
177
src/loop/loopClaude.ts
Normal file
@@ -0,0 +1,177 @@
|
|||||||
|
/**
|
||||||
|
* Copyright (c) Microsoft Corporation.
|
||||||
|
*
|
||||||
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
* you may not use this file except in compliance with the License.
|
||||||
|
* You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing, software
|
||||||
|
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
* See the License for the specific language governing permissions and
|
||||||
|
* limitations under the License.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type Anthropic from '@anthropic-ai/sdk';
|
||||||
|
import type { LLMDelegate, LLMConversation, LLMToolCall, LLMTool } from './loop.js';
|
||||||
|
import type { Tool } from '@modelcontextprotocol/sdk/types.js';
|
||||||
|
|
||||||
|
const model = 'claude-sonnet-4-20250514';
|
||||||
|
|
||||||
|
export class ClaudeDelegate implements LLMDelegate {
|
||||||
|
private _anthropic: Anthropic | undefined;
|
||||||
|
|
||||||
|
async anthropic(): Promise<Anthropic> {
|
||||||
|
if (!this._anthropic) {
|
||||||
|
const anthropic = await import('@anthropic-ai/sdk');
|
||||||
|
this._anthropic = new anthropic.Anthropic();
|
||||||
|
}
|
||||||
|
return this._anthropic;
|
||||||
|
}
|
||||||
|
|
||||||
|
createConversation(task: string, tools: Tool[], oneShot: boolean): LLMConversation {
|
||||||
|
const llmTools: LLMTool[] = tools.map(tool => ({
|
||||||
|
name: tool.name,
|
||||||
|
description: tool.description || '',
|
||||||
|
inputSchema: tool.inputSchema,
|
||||||
|
}));
|
||||||
|
|
||||||
|
if (!oneShot) {
|
||||||
|
llmTools.push({
|
||||||
|
name: 'done',
|
||||||
|
description: 'Call this tool when the task is complete.',
|
||||||
|
inputSchema: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
messages: [{
|
||||||
|
role: 'user',
|
||||||
|
content: task
|
||||||
|
}],
|
||||||
|
tools: llmTools,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async makeApiCall(conversation: LLMConversation): Promise<LLMToolCall[]> {
|
||||||
|
// Convert generic messages to Claude format
|
||||||
|
const claudeMessages: Anthropic.Messages.MessageParam[] = [];
|
||||||
|
|
||||||
|
for (const message of conversation.messages) {
|
||||||
|
if (message.role === 'user') {
|
||||||
|
claudeMessages.push({
|
||||||
|
role: 'user',
|
||||||
|
content: message.content
|
||||||
|
});
|
||||||
|
} else if (message.role === 'assistant') {
|
||||||
|
const content: Anthropic.Messages.ContentBlock[] = [];
|
||||||
|
|
||||||
|
// Add text content
|
||||||
|
if (message.content) {
|
||||||
|
content.push({
|
||||||
|
type: 'text',
|
||||||
|
text: message.content,
|
||||||
|
citations: []
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add tool calls
|
||||||
|
if (message.toolCalls) {
|
||||||
|
for (const toolCall of message.toolCalls) {
|
||||||
|
content.push({
|
||||||
|
type: 'tool_use',
|
||||||
|
id: toolCall.id,
|
||||||
|
name: toolCall.name,
|
||||||
|
input: toolCall.arguments
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
claudeMessages.push({
|
||||||
|
role: 'assistant',
|
||||||
|
content
|
||||||
|
});
|
||||||
|
} else if (message.role === 'tool') {
|
||||||
|
// Tool results are added differently - we need to find if there's already a user message with tool results
|
||||||
|
const lastMessage = claudeMessages[claudeMessages.length - 1];
|
||||||
|
const toolResult: Anthropic.Messages.ToolResultBlockParam = {
|
||||||
|
type: 'tool_result',
|
||||||
|
tool_use_id: message.toolCallId,
|
||||||
|
content: message.content,
|
||||||
|
is_error: message.isError,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (lastMessage && lastMessage.role === 'user' && Array.isArray(lastMessage.content)) {
|
||||||
|
// Add to existing tool results message
|
||||||
|
(lastMessage.content as Anthropic.Messages.ToolResultBlockParam[]).push(toolResult);
|
||||||
|
} else {
|
||||||
|
// Create new tool results message
|
||||||
|
claudeMessages.push({
|
||||||
|
role: 'user',
|
||||||
|
content: [toolResult]
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert generic tools to Claude format
|
||||||
|
const claudeTools: Anthropic.Messages.Tool[] = conversation.tools.map(tool => ({
|
||||||
|
name: tool.name,
|
||||||
|
description: tool.description,
|
||||||
|
input_schema: tool.inputSchema,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const anthropic = await this.anthropic();
|
||||||
|
const response = await anthropic.messages.create({
|
||||||
|
model,
|
||||||
|
max_tokens: 10000,
|
||||||
|
messages: claudeMessages,
|
||||||
|
tools: claudeTools,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Extract tool calls and add assistant message to generic conversation
|
||||||
|
const toolCalls = response.content.filter(block => block.type === 'tool_use') as Anthropic.Messages.ToolUseBlock[];
|
||||||
|
const textContent = response.content.filter(block => block.type === 'text').map(block => (block as Anthropic.Messages.TextBlock).text).join('');
|
||||||
|
|
||||||
|
const llmToolCalls: LLMToolCall[] = toolCalls.map(toolCall => ({
|
||||||
|
name: toolCall.name,
|
||||||
|
arguments: toolCall.input as any,
|
||||||
|
id: toolCall.id,
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Add assistant message to generic conversation
|
||||||
|
conversation.messages.push({
|
||||||
|
role: 'assistant',
|
||||||
|
content: textContent,
|
||||||
|
toolCalls: llmToolCalls.length > 0 ? llmToolCalls : undefined
|
||||||
|
});
|
||||||
|
|
||||||
|
return llmToolCalls;
|
||||||
|
}
|
||||||
|
|
||||||
|
addToolResults(
|
||||||
|
conversation: LLMConversation,
|
||||||
|
results: Array<{ toolCallId: string; content: string; isError?: boolean }>
|
||||||
|
): void {
|
||||||
|
for (const result of results) {
|
||||||
|
conversation.messages.push({
|
||||||
|
role: 'tool',
|
||||||
|
toolCallId: result.toolCallId,
|
||||||
|
content: result.content,
|
||||||
|
isError: result.isError,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
checkDoneToolCall(toolCall: LLMToolCall): string | null {
|
||||||
|
if (toolCall.name === 'done')
|
||||||
|
return (toolCall.arguments as { result: string }).result;
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
168
src/loop/loopOpenAI.ts
Normal file
168
src/loop/loopOpenAI.ts
Normal file
@@ -0,0 +1,168 @@
|
|||||||
|
/**
|
||||||
|
* Copyright (c) Microsoft Corporation.
|
||||||
|
*
|
||||||
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
* you may not use this file except in compliance with the License.
|
||||||
|
* You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing, software
|
||||||
|
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
* See the License for the specific language governing permissions and
|
||||||
|
* limitations under the License.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type OpenAI from 'openai';
|
||||||
|
import type { LLMDelegate, LLMConversation, LLMToolCall, LLMTool } from './loop.js';
|
||||||
|
import type { Tool } from '@modelcontextprotocol/sdk/types.js';
|
||||||
|
|
||||||
|
const model = 'gpt-4.1';
|
||||||
|
|
||||||
|
export class OpenAIDelegate implements LLMDelegate {
|
||||||
|
private _openai: OpenAI | undefined;
|
||||||
|
|
||||||
|
async openai(): Promise<OpenAI> {
|
||||||
|
if (!this._openai) {
|
||||||
|
const oai = await import('openai');
|
||||||
|
this._openai = new oai.OpenAI();
|
||||||
|
}
|
||||||
|
return this._openai;
|
||||||
|
}
|
||||||
|
|
||||||
|
createConversation(task: string, tools: Tool[], oneShot: boolean): LLMConversation {
|
||||||
|
const genericTools: LLMTool[] = tools.map(tool => ({
|
||||||
|
name: tool.name,
|
||||||
|
description: tool.description || '',
|
||||||
|
inputSchema: tool.inputSchema,
|
||||||
|
}));
|
||||||
|
|
||||||
|
if (!oneShot) {
|
||||||
|
genericTools.push({
|
||||||
|
name: 'done',
|
||||||
|
description: 'Call this tool when the task is complete.',
|
||||||
|
inputSchema: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
messages: [{
|
||||||
|
role: 'user',
|
||||||
|
content: task
|
||||||
|
}],
|
||||||
|
tools: genericTools,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async makeApiCall(conversation: LLMConversation): Promise<LLMToolCall[]> {
|
||||||
|
// Convert generic messages to OpenAI format
|
||||||
|
const openaiMessages: OpenAI.Chat.Completions.ChatCompletionMessageParam[] = [];
|
||||||
|
|
||||||
|
for (const message of conversation.messages) {
|
||||||
|
if (message.role === 'user') {
|
||||||
|
openaiMessages.push({
|
||||||
|
role: 'user',
|
||||||
|
content: message.content
|
||||||
|
});
|
||||||
|
} else if (message.role === 'assistant') {
|
||||||
|
const toolCalls: OpenAI.Chat.Completions.ChatCompletionMessageToolCall[] = [];
|
||||||
|
|
||||||
|
if (message.toolCalls) {
|
||||||
|
for (const toolCall of message.toolCalls) {
|
||||||
|
toolCalls.push({
|
||||||
|
id: toolCall.id,
|
||||||
|
type: 'function',
|
||||||
|
function: {
|
||||||
|
name: toolCall.name,
|
||||||
|
arguments: JSON.stringify(toolCall.arguments)
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const assistantMessage: OpenAI.Chat.Completions.ChatCompletionAssistantMessageParam = {
|
||||||
|
role: 'assistant'
|
||||||
|
};
|
||||||
|
|
||||||
|
if (message.content)
|
||||||
|
assistantMessage.content = message.content;
|
||||||
|
|
||||||
|
if (toolCalls.length > 0)
|
||||||
|
assistantMessage.tool_calls = toolCalls;
|
||||||
|
|
||||||
|
openaiMessages.push(assistantMessage);
|
||||||
|
} else if (message.role === 'tool') {
|
||||||
|
openaiMessages.push({
|
||||||
|
role: 'tool',
|
||||||
|
tool_call_id: message.toolCallId,
|
||||||
|
content: message.content,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert generic tools to OpenAI format
|
||||||
|
const openaiTools: OpenAI.Chat.Completions.ChatCompletionTool[] = conversation.tools.map(tool => ({
|
||||||
|
type: 'function',
|
||||||
|
function: {
|
||||||
|
name: tool.name,
|
||||||
|
description: tool.description,
|
||||||
|
parameters: tool.inputSchema,
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
const openai = await this.openai();
|
||||||
|
const response = await openai.chat.completions.create({
|
||||||
|
model,
|
||||||
|
messages: openaiMessages,
|
||||||
|
tools: openaiTools,
|
||||||
|
tool_choice: 'auto'
|
||||||
|
});
|
||||||
|
|
||||||
|
const message = response.choices[0].message;
|
||||||
|
|
||||||
|
// Extract tool calls and add assistant message to generic conversation
|
||||||
|
const toolCalls = message.tool_calls || [];
|
||||||
|
const genericToolCalls: LLMToolCall[] = toolCalls.map(toolCall => {
|
||||||
|
const functionCall = toolCall.function;
|
||||||
|
return {
|
||||||
|
name: functionCall.name,
|
||||||
|
arguments: JSON.parse(functionCall.arguments),
|
||||||
|
id: toolCall.id,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
// Add assistant message to generic conversation
|
||||||
|
conversation.messages.push({
|
||||||
|
role: 'assistant',
|
||||||
|
content: message.content || '',
|
||||||
|
toolCalls: genericToolCalls.length > 0 ? genericToolCalls : undefined
|
||||||
|
});
|
||||||
|
|
||||||
|
return genericToolCalls;
|
||||||
|
}
|
||||||
|
|
||||||
|
addToolResults(
|
||||||
|
conversation: LLMConversation,
|
||||||
|
results: Array<{ toolCallId: string; content: string; isError?: boolean }>
|
||||||
|
): void {
|
||||||
|
for (const result of results) {
|
||||||
|
conversation.messages.push({
|
||||||
|
role: 'tool',
|
||||||
|
toolCallId: result.toolCallId,
|
||||||
|
content: result.content,
|
||||||
|
isError: result.isError,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
checkDoneToolCall(toolCall: LLMToolCall): string | null {
|
||||||
|
if (toolCall.name === 'done')
|
||||||
|
return toolCall.arguments.result;
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
72
src/loop/main.ts
Normal file
72
src/loop/main.ts
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
/**
|
||||||
|
* Copyright (c) Microsoft Corporation.
|
||||||
|
*
|
||||||
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
* you may not use this file except in compliance with the License.
|
||||||
|
* You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing, software
|
||||||
|
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
* See the License for the specific language governing permissions and
|
||||||
|
* limitations under the License.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/* eslint-disable no-console */
|
||||||
|
|
||||||
|
import path from 'path';
|
||||||
|
import url from 'url';
|
||||||
|
import dotenv from 'dotenv';
|
||||||
|
|
||||||
|
import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js';
|
||||||
|
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
|
||||||
|
import { program } from 'commander';
|
||||||
|
import { OpenAIDelegate } from './loopOpenAI.js';
|
||||||
|
import { ClaudeDelegate } from './loopClaude.js';
|
||||||
|
import { runTask } from './loop.js';
|
||||||
|
|
||||||
|
import type { LLMDelegate } from './loop.js';
|
||||||
|
|
||||||
|
dotenv.config();
|
||||||
|
|
||||||
|
const __filename = url.fileURLToPath(import.meta.url);
|
||||||
|
|
||||||
|
async function run(delegate: LLMDelegate) {
|
||||||
|
const transport = new StdioClientTransport({
|
||||||
|
command: 'node',
|
||||||
|
args: [
|
||||||
|
path.resolve(__filename, '../../../cli.js'),
|
||||||
|
'--save-session',
|
||||||
|
'--output-dir', path.resolve(__filename, '../../../sessions')
|
||||||
|
],
|
||||||
|
stderr: 'inherit',
|
||||||
|
env: process.env as Record<string, string>,
|
||||||
|
});
|
||||||
|
|
||||||
|
const client = new Client({ name: 'test', version: '1.0.0' });
|
||||||
|
await client.connect(transport);
|
||||||
|
await client.ping();
|
||||||
|
|
||||||
|
for (const task of tasks) {
|
||||||
|
const messages = await runTask(delegate, client, task);
|
||||||
|
for (const message of messages)
|
||||||
|
console.log(`${message.role}: ${message.content}`);
|
||||||
|
}
|
||||||
|
await client.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
const tasks = [
|
||||||
|
'Open https://playwright.dev/',
|
||||||
|
];
|
||||||
|
|
||||||
|
program
|
||||||
|
.option('--model <model>', 'model to use')
|
||||||
|
.action(async options => {
|
||||||
|
if (options.model === 'claude')
|
||||||
|
await run(new ClaudeDelegate());
|
||||||
|
else
|
||||||
|
await run(new OpenAIDelegate());
|
||||||
|
});
|
||||||
|
void program.parseAsync(process.argv);
|
||||||
77
src/loopTools/context.ts
Normal file
77
src/loopTools/context.ts
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
/**
|
||||||
|
* Copyright (c) Microsoft Corporation.
|
||||||
|
*
|
||||||
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
* you may not use this file except in compliance with the License.
|
||||||
|
* You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing, software
|
||||||
|
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
* See the License for the specific language governing permissions and
|
||||||
|
* limitations under the License.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
|
||||||
|
import { contextFactory } from '../browserContextFactory.js';
|
||||||
|
import { BrowserServerBackend } from '../browserServerBackend.js';
|
||||||
|
import { Context as BrowserContext } from '../context.js';
|
||||||
|
import { runTask } from '../loop/loop.js';
|
||||||
|
import { OpenAIDelegate } from '../loop/loopOpenAI.js';
|
||||||
|
import { ClaudeDelegate } from '../loop/loopClaude.js';
|
||||||
|
import { InProcessTransport } from '../mcp/inProcessTransport.js';
|
||||||
|
import * as mcpServer from '../mcp/server.js';
|
||||||
|
|
||||||
|
import type { LLMDelegate } from '../loop/loop.js';
|
||||||
|
import type { FullConfig } from '../config.js';
|
||||||
|
|
||||||
|
export class Context {
|
||||||
|
readonly config: FullConfig;
|
||||||
|
private _client: Client;
|
||||||
|
private _delegate: LLMDelegate;
|
||||||
|
|
||||||
|
constructor(config: FullConfig, client: Client) {
|
||||||
|
this.config = config;
|
||||||
|
this._client = client;
|
||||||
|
if (process.env.OPENAI_API_KEY)
|
||||||
|
this._delegate = new OpenAIDelegate();
|
||||||
|
else if (process.env.ANTHROPIC_API_KEY)
|
||||||
|
this._delegate = new ClaudeDelegate();
|
||||||
|
else
|
||||||
|
throw new Error('No LLM API key found. Please set OPENAI_API_KEY or ANTHROPIC_API_KEY environment variable.');
|
||||||
|
}
|
||||||
|
|
||||||
|
static async create(config: FullConfig) {
|
||||||
|
const client = new Client({ name: 'Playwright Proxy', version: '1.0.0' });
|
||||||
|
const browserContextFactory = contextFactory(config);
|
||||||
|
const server = mcpServer.createServer(new BrowserServerBackend(config, [browserContextFactory]), false);
|
||||||
|
await client.connect(new InProcessTransport(server));
|
||||||
|
await client.ping();
|
||||||
|
return new Context(config, client);
|
||||||
|
}
|
||||||
|
|
||||||
|
async runTask(task: string, oneShot: boolean = false): Promise<mcpServer.ToolResponse> {
|
||||||
|
const messages = await runTask(this._delegate, this._client!, task, oneShot);
|
||||||
|
const lines: string[] = [];
|
||||||
|
|
||||||
|
// Skip the first message, which is the user's task.
|
||||||
|
for (const message of messages.slice(1)) {
|
||||||
|
// Trim out all page snapshots.
|
||||||
|
if (!message.content.trim())
|
||||||
|
continue;
|
||||||
|
const index = oneShot ? -1 : message.content.indexOf('### Page state');
|
||||||
|
const trimmedContent = index === -1 ? message.content : message.content.substring(0, index);
|
||||||
|
lines.push(`[${message.role}]:`, trimmedContent);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
content: [{ type: 'text', text: lines.join('\n') }],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async close() {
|
||||||
|
await BrowserContext.disposeAll();
|
||||||
|
}
|
||||||
|
}
|
||||||
63
src/loopTools/main.ts
Normal file
63
src/loopTools/main.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 dotenv from 'dotenv';
|
||||||
|
|
||||||
|
import * as mcpServer from '../mcp/server.js';
|
||||||
|
import * as mcpTransport from '../mcp/transport.js';
|
||||||
|
import { packageJSON } from '../package.js';
|
||||||
|
import { Context } from './context.js';
|
||||||
|
import { perform } from './perform.js';
|
||||||
|
import { snapshot } from './snapshot.js';
|
||||||
|
|
||||||
|
import type { FullConfig } from '../config.js';
|
||||||
|
import type { ServerBackend } from '../mcp/server.js';
|
||||||
|
import type { Tool } from './tool.js';
|
||||||
|
|
||||||
|
export async function runLoopTools(config: FullConfig) {
|
||||||
|
dotenv.config();
|
||||||
|
const serverBackendFactory = () => new LoopToolsServerBackend(config);
|
||||||
|
await mcpTransport.start(serverBackendFactory, config.server);
|
||||||
|
}
|
||||||
|
|
||||||
|
class LoopToolsServerBackend implements ServerBackend {
|
||||||
|
readonly name = 'Playwright';
|
||||||
|
readonly version = packageJSON.version;
|
||||||
|
private _config: FullConfig;
|
||||||
|
private _context: Context | undefined;
|
||||||
|
private _tools: Tool<any>[] = [perform, snapshot];
|
||||||
|
|
||||||
|
constructor(config: FullConfig) {
|
||||||
|
this._config = config;
|
||||||
|
}
|
||||||
|
|
||||||
|
async initialize() {
|
||||||
|
this._context = await Context.create(this._config);
|
||||||
|
}
|
||||||
|
|
||||||
|
tools(): mcpServer.ToolSchema<any>[] {
|
||||||
|
return this._tools.map(tool => tool.schema);
|
||||||
|
}
|
||||||
|
|
||||||
|
async callTool(schema: mcpServer.ToolSchema<any>, parsedArguments: any): Promise<mcpServer.ToolResponse> {
|
||||||
|
const tool = this._tools.find(tool => tool.schema.name === schema.name)!;
|
||||||
|
return await tool.handle(this._context!, parsedArguments);
|
||||||
|
}
|
||||||
|
|
||||||
|
serverClosed() {
|
||||||
|
void this._context!.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
36
src/loopTools/perform.ts
Normal file
36
src/loopTools/perform.ts
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
/**
|
||||||
|
* Copyright (c) Microsoft Corporation.
|
||||||
|
*
|
||||||
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
* you may not use this file except in compliance with the License.
|
||||||
|
* You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing, software
|
||||||
|
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
* See the License for the specific language governing permissions and
|
||||||
|
* limitations under the License.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { z } from 'zod';
|
||||||
|
import { defineTool } from './tool.js';
|
||||||
|
|
||||||
|
const performSchema = z.object({
|
||||||
|
task: z.string().describe('The task to perform with the browser'),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const perform = defineTool({
|
||||||
|
schema: {
|
||||||
|
name: 'browser_perform',
|
||||||
|
title: 'Perform a task with the browser',
|
||||||
|
description: 'Perform a task with the browser. It can click, type, export, capture screenshot, drag, hover, select options, etc.',
|
||||||
|
inputSchema: performSchema,
|
||||||
|
type: 'destructive',
|
||||||
|
},
|
||||||
|
|
||||||
|
handle: async (context, params) => {
|
||||||
|
return await context.runTask(params.task);
|
||||||
|
},
|
||||||
|
});
|
||||||
32
src/loopTools/snapshot.ts
Normal file
32
src/loopTools/snapshot.ts
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
/**
|
||||||
|
* Copyright (c) Microsoft Corporation.
|
||||||
|
*
|
||||||
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
* you may not use this file except in compliance with the License.
|
||||||
|
* You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing, software
|
||||||
|
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
* See the License for the specific language governing permissions and
|
||||||
|
* limitations under the License.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { z } from 'zod';
|
||||||
|
import { defineTool } from './tool.js';
|
||||||
|
|
||||||
|
export const snapshot = defineTool({
|
||||||
|
schema: {
|
||||||
|
name: 'browser_snapshot',
|
||||||
|
title: 'Take a snapshot of the browser',
|
||||||
|
description: 'Take a snapshot of the browser to read what is on the page.',
|
||||||
|
inputSchema: z.object({}),
|
||||||
|
type: 'readOnly',
|
||||||
|
},
|
||||||
|
|
||||||
|
handle: async (context, params) => {
|
||||||
|
return await context.runTask('Capture browser snapshot', true);
|
||||||
|
},
|
||||||
|
});
|
||||||
29
src/loopTools/tool.ts
Normal file
29
src/loopTools/tool.ts
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
/**
|
||||||
|
* Copyright (c) Microsoft Corporation.
|
||||||
|
*
|
||||||
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
* you may not use this file except in compliance with the License.
|
||||||
|
* You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing, software
|
||||||
|
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
* See the License for the specific language governing permissions and
|
||||||
|
* limitations under the License.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { z } from 'zod';
|
||||||
|
import type * as mcpServer from '../mcp/server.js';
|
||||||
|
import type { Context } from './context.js';
|
||||||
|
|
||||||
|
|
||||||
|
export type Tool<Input extends z.Schema = z.Schema> = {
|
||||||
|
schema: mcpServer.ToolSchema<Input>;
|
||||||
|
handle: (context: Context, params: z.output<Input>) => Promise<mcpServer.ToolResponse>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function defineTool<Input extends z.Schema>(tool: Tool<Input>): Tool<Input> {
|
||||||
|
return tool;
|
||||||
|
}
|
||||||
1
src/mcp/README.md
Normal file
1
src/mcp/README.md
Normal file
@@ -0,0 +1 @@
|
|||||||
|
- Generic MCP utils, no dependencies on Playwright here.
|
||||||
92
src/mcp/inProcessTransport.ts
Normal file
92
src/mcp/inProcessTransport.ts
Normal file
@@ -0,0 +1,92 @@
|
|||||||
|
/**
|
||||||
|
* Copyright (c) Microsoft Corporation.
|
||||||
|
*
|
||||||
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
* you may not use this file except in compliance with the License.
|
||||||
|
* You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing, software
|
||||||
|
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
* See the License for the specific language governing permissions and
|
||||||
|
* limitations under the License.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { Server } from '@modelcontextprotocol/sdk/server/index.js';
|
||||||
|
import type { Transport, TransportSendOptions } from '@modelcontextprotocol/sdk/shared/transport.js';
|
||||||
|
import type { JSONRPCMessage, MessageExtraInfo } from '@modelcontextprotocol/sdk/types.js';
|
||||||
|
|
||||||
|
export class InProcessTransport implements Transport {
|
||||||
|
private _server: Server;
|
||||||
|
private _serverTransport: InProcessServerTransport;
|
||||||
|
private _connected: boolean = false;
|
||||||
|
|
||||||
|
constructor(server: Server) {
|
||||||
|
this._server = server;
|
||||||
|
this._serverTransport = new InProcessServerTransport(this);
|
||||||
|
}
|
||||||
|
|
||||||
|
async start(): Promise<void> {
|
||||||
|
if (this._connected)
|
||||||
|
throw new Error('InprocessTransport already started!');
|
||||||
|
|
||||||
|
await this._server.connect(this._serverTransport);
|
||||||
|
this._connected = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
async send(message: JSONRPCMessage, options?: TransportSendOptions): Promise<void> {
|
||||||
|
if (!this._connected)
|
||||||
|
throw new Error('Transport not connected');
|
||||||
|
|
||||||
|
|
||||||
|
this._serverTransport._receiveFromClient(message);
|
||||||
|
}
|
||||||
|
|
||||||
|
async close(): Promise<void> {
|
||||||
|
if (this._connected) {
|
||||||
|
this._connected = false;
|
||||||
|
this.onclose?.();
|
||||||
|
this._serverTransport.onclose?.();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onclose?: (() => void) | undefined;
|
||||||
|
onerror?: ((error: Error) => void) | undefined;
|
||||||
|
onmessage?: ((message: JSONRPCMessage, extra?: MessageExtraInfo) => void) | undefined;
|
||||||
|
sessionId?: string | undefined;
|
||||||
|
setProtocolVersion?: ((version: string) => void) | undefined;
|
||||||
|
|
||||||
|
_receiveFromServer(message: JSONRPCMessage, extra?: MessageExtraInfo): void {
|
||||||
|
this.onmessage?.(message, extra);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class InProcessServerTransport implements Transport {
|
||||||
|
private _clientTransport: InProcessTransport;
|
||||||
|
|
||||||
|
constructor(clientTransport: InProcessTransport) {
|
||||||
|
this._clientTransport = clientTransport;
|
||||||
|
}
|
||||||
|
|
||||||
|
async start(): Promise<void> {
|
||||||
|
}
|
||||||
|
|
||||||
|
async send(message: JSONRPCMessage, options?: TransportSendOptions): Promise<void> {
|
||||||
|
this._clientTransport._receiveFromServer(message);
|
||||||
|
}
|
||||||
|
|
||||||
|
async close(): Promise<void> {
|
||||||
|
this.onclose?.();
|
||||||
|
}
|
||||||
|
|
||||||
|
onclose?: (() => void) | undefined;
|
||||||
|
onerror?: ((error: Error) => void) | undefined;
|
||||||
|
onmessage?: ((message: JSONRPCMessage, extra?: MessageExtraInfo) => void) | undefined;
|
||||||
|
sessionId?: string | undefined;
|
||||||
|
setProtocolVersion?: ((version: string) => void) | undefined;
|
||||||
|
_receiveFromClient(message: JSONRPCMessage): void {
|
||||||
|
this.onmessage?.(message);
|
||||||
|
}
|
||||||
|
}
|
||||||
140
src/mcp/server.ts
Normal file
140
src/mcp/server.ts
Normal file
@@ -0,0 +1,140 @@
|
|||||||
|
/**
|
||||||
|
* Copyright (c) Microsoft Corporation.
|
||||||
|
*
|
||||||
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
* you may not use this file except in compliance with the License.
|
||||||
|
* You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing, software
|
||||||
|
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
* See the License for the specific language governing permissions and
|
||||||
|
* limitations under the License.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { z } from 'zod';
|
||||||
|
import { Server } from '@modelcontextprotocol/sdk/server/index.js';
|
||||||
|
import { CallToolRequestSchema, ListToolsRequestSchema } from '@modelcontextprotocol/sdk/types.js';
|
||||||
|
import { zodToJsonSchema } from 'zod-to-json-schema';
|
||||||
|
import { ManualPromise } from '../manualPromise.js';
|
||||||
|
import { logUnhandledError } from '../log.js';
|
||||||
|
|
||||||
|
import type { ImageContent, TextContent } from '@modelcontextprotocol/sdk/types.js';
|
||||||
|
import type { Transport } from '@modelcontextprotocol/sdk/shared/transport.js';
|
||||||
|
export type { Server } from '@modelcontextprotocol/sdk/server/index.js';
|
||||||
|
|
||||||
|
export type ClientCapabilities = {
|
||||||
|
roots?: {
|
||||||
|
listRoots?: boolean
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ToolResponse = {
|
||||||
|
content: (TextContent | ImageContent)[];
|
||||||
|
isError?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ToolSchema<Input extends z.Schema> = {
|
||||||
|
name: string;
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
inputSchema: Input;
|
||||||
|
type: 'readOnly' | 'destructive';
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ToolHandler = (toolName: string, params: any) => Promise<ToolResponse>;
|
||||||
|
|
||||||
|
export interface ServerBackend {
|
||||||
|
name: string;
|
||||||
|
version: string;
|
||||||
|
initialize?(server: Server): Promise<void>;
|
||||||
|
tools(): ToolSchema<any>[];
|
||||||
|
callTool(schema: ToolSchema<any>, parsedArguments: any): Promise<ToolResponse>;
|
||||||
|
serverClosed?(): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ServerBackendFactory = () => ServerBackend;
|
||||||
|
|
||||||
|
export async function connect(serverBackendFactory: ServerBackendFactory, transport: Transport, runHeartbeat: boolean) {
|
||||||
|
const backend = serverBackendFactory();
|
||||||
|
const server = createServer(backend, runHeartbeat);
|
||||||
|
await server.connect(transport);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createServer(backend: ServerBackend, runHeartbeat: boolean): Server {
|
||||||
|
const initializedPromise = new ManualPromise<void>();
|
||||||
|
const server = new Server({ name: backend.name, version: backend.version }, {
|
||||||
|
capabilities: {
|
||||||
|
tools: {},
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const tools = backend.tools();
|
||||||
|
server.setRequestHandler(ListToolsRequestSchema, async () => {
|
||||||
|
return { tools: tools.map(tool => ({
|
||||||
|
name: tool.name,
|
||||||
|
description: tool.description,
|
||||||
|
inputSchema: zodToJsonSchema(tool.inputSchema),
|
||||||
|
annotations: {
|
||||||
|
title: tool.title,
|
||||||
|
readOnlyHint: tool.type === 'readOnly',
|
||||||
|
destructiveHint: tool.type === 'destructive',
|
||||||
|
openWorldHint: true,
|
||||||
|
},
|
||||||
|
})) };
|
||||||
|
});
|
||||||
|
|
||||||
|
let heartbeatRunning = false;
|
||||||
|
server.setRequestHandler(CallToolRequestSchema, async request => {
|
||||||
|
await initializedPromise;
|
||||||
|
|
||||||
|
if (runHeartbeat && !heartbeatRunning) {
|
||||||
|
heartbeatRunning = true;
|
||||||
|
startHeartbeat(server);
|
||||||
|
}
|
||||||
|
|
||||||
|
const errorResult = (...messages: string[]) => ({
|
||||||
|
content: [{ type: 'text', text: '### Result\n' + messages.join('\n') }],
|
||||||
|
isError: true,
|
||||||
|
});
|
||||||
|
const tool = tools.find(tool => tool.name === request.params.name) as ToolSchema<any>;
|
||||||
|
if (!tool)
|
||||||
|
return errorResult(`Error: Tool "${request.params.name}" not found`);
|
||||||
|
|
||||||
|
try {
|
||||||
|
return await backend.callTool(tool, tool.inputSchema.parse(request.params.arguments || {}));
|
||||||
|
} catch (error) {
|
||||||
|
return errorResult(String(error));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
addServerListener(server, 'initialized', () => {
|
||||||
|
backend.initialize?.(server).then(() => initializedPromise.resolve()).catch(logUnhandledError);
|
||||||
|
});
|
||||||
|
addServerListener(server, 'close', () => backend.serverClosed?.());
|
||||||
|
return server;
|
||||||
|
}
|
||||||
|
|
||||||
|
const startHeartbeat = (server: Server) => {
|
||||||
|
const beat = () => {
|
||||||
|
Promise.race([
|
||||||
|
server.ping(),
|
||||||
|
new Promise((_, reject) => setTimeout(() => reject(new Error('ping timeout')), 5000)),
|
||||||
|
]).then(() => {
|
||||||
|
setTimeout(beat, 3000);
|
||||||
|
}).catch(() => {
|
||||||
|
void server.close();
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
beat();
|
||||||
|
};
|
||||||
|
|
||||||
|
function addServerListener(server: Server, event: 'close' | 'initialized', listener: () => void) {
|
||||||
|
const oldListener = server[`on${event}`];
|
||||||
|
server[`on${event}`] = () => {
|
||||||
|
oldListener?.();
|
||||||
|
listener();
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -14,24 +14,34 @@
|
|||||||
* limitations under the License.
|
* limitations under the License.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import http from 'node:http';
|
import http from 'http';
|
||||||
import assert from 'node:assert';
|
import crypto from 'crypto';
|
||||||
import crypto from 'node:crypto';
|
|
||||||
|
|
||||||
import debug from 'debug';
|
import debug from 'debug';
|
||||||
|
|
||||||
import { SSEServerTransport } from '@modelcontextprotocol/sdk/server/sse.js';
|
import { SSEServerTransport } from '@modelcontextprotocol/sdk/server/sse.js';
|
||||||
import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
|
import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
|
||||||
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
||||||
|
import { httpAddressToString, startHttpServer } from '../httpServer.js';
|
||||||
|
import * as mcpServer from './server.js';
|
||||||
|
|
||||||
import type { Server } from './server.js';
|
import type { ServerBackendFactory } from './server.js';
|
||||||
|
|
||||||
export async function startStdioTransport(server: Server) {
|
export async function start(serverBackendFactory: ServerBackendFactory, options: { host?: string; port?: number }) {
|
||||||
await server.createConnection(new StdioServerTransport());
|
if (options.port !== undefined) {
|
||||||
|
const httpServer = await startHttpServer(options);
|
||||||
|
startHttpTransport(httpServer, serverBackendFactory);
|
||||||
|
} else {
|
||||||
|
await startStdioTransport(serverBackendFactory);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function startStdioTransport(serverBackendFactory: ServerBackendFactory) {
|
||||||
|
await mcpServer.connect(serverBackendFactory, new StdioServerTransport(), false);
|
||||||
}
|
}
|
||||||
|
|
||||||
const testDebug = debug('pw:mcp:test');
|
const testDebug = debug('pw:mcp:test');
|
||||||
|
|
||||||
async function handleSSE(server: Server, req: http.IncomingMessage, res: http.ServerResponse, url: URL, sessions: Map<string, SSEServerTransport>) {
|
async function handleSSE(serverBackendFactory: ServerBackendFactory, req: http.IncomingMessage, res: http.ServerResponse, url: URL, sessions: Map<string, SSEServerTransport>) {
|
||||||
if (req.method === 'POST') {
|
if (req.method === 'POST') {
|
||||||
const sessionId = url.searchParams.get('sessionId');
|
const sessionId = url.searchParams.get('sessionId');
|
||||||
if (!sessionId) {
|
if (!sessionId) {
|
||||||
@@ -50,12 +60,10 @@ async function handleSSE(server: Server, req: http.IncomingMessage, res: http.Se
|
|||||||
const transport = new SSEServerTransport('/sse', res);
|
const transport = new SSEServerTransport('/sse', res);
|
||||||
sessions.set(transport.sessionId, transport);
|
sessions.set(transport.sessionId, transport);
|
||||||
testDebug(`create SSE session: ${transport.sessionId}`);
|
testDebug(`create SSE session: ${transport.sessionId}`);
|
||||||
const connection = await server.createConnection(transport);
|
await mcpServer.connect(serverBackendFactory, transport, false);
|
||||||
res.on('close', () => {
|
res.on('close', () => {
|
||||||
testDebug(`delete SSE session: ${transport.sessionId}`);
|
testDebug(`delete SSE session: ${transport.sessionId}`);
|
||||||
sessions.delete(transport.sessionId);
|
sessions.delete(transport.sessionId);
|
||||||
// eslint-disable-next-line no-console
|
|
||||||
void connection.close().catch(e => console.error(e));
|
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -64,7 +72,7 @@ async function handleSSE(server: Server, req: http.IncomingMessage, res: http.Se
|
|||||||
res.end('Method not allowed');
|
res.end('Method not allowed');
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleStreamable(server: Server, req: http.IncomingMessage, res: http.ServerResponse, sessions: Map<string, StreamableHTTPServerTransport>) {
|
async function handleStreamable(serverBackendFactory: ServerBackendFactory, req: http.IncomingMessage, res: http.ServerResponse, sessions: Map<string, StreamableHTTPServerTransport>) {
|
||||||
const sessionId = req.headers['mcp-session-id'] as string | undefined;
|
const sessionId = req.headers['mcp-session-id'] as string | undefined;
|
||||||
if (sessionId) {
|
if (sessionId) {
|
||||||
const transport = sessions.get(sessionId);
|
const transport = sessions.get(sessionId);
|
||||||
@@ -79,15 +87,20 @@ async function handleStreamable(server: Server, req: http.IncomingMessage, res:
|
|||||||
if (req.method === 'POST') {
|
if (req.method === 'POST') {
|
||||||
const transport = new StreamableHTTPServerTransport({
|
const transport = new StreamableHTTPServerTransport({
|
||||||
sessionIdGenerator: () => crypto.randomUUID(),
|
sessionIdGenerator: () => crypto.randomUUID(),
|
||||||
onsessioninitialized: sessionId => {
|
onsessioninitialized: async sessionId => {
|
||||||
|
testDebug(`create http session: ${transport.sessionId}`);
|
||||||
|
await mcpServer.connect(serverBackendFactory, transport, true);
|
||||||
sessions.set(sessionId, transport);
|
sessions.set(sessionId, transport);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
transport.onclose = () => {
|
transport.onclose = () => {
|
||||||
if (transport.sessionId)
|
if (!transport.sessionId)
|
||||||
sessions.delete(transport.sessionId);
|
return;
|
||||||
|
sessions.delete(transport.sessionId);
|
||||||
|
testDebug(`delete http session: ${transport.sessionId}`);
|
||||||
};
|
};
|
||||||
await server.createConnection(transport);
|
|
||||||
await transport.handleRequest(req, res);
|
await transport.handleRequest(req, res);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -96,43 +109,29 @@ async function handleStreamable(server: Server, req: http.IncomingMessage, res:
|
|||||||
res.end('Invalid request');
|
res.end('Invalid request');
|
||||||
}
|
}
|
||||||
|
|
||||||
export function startHttpTransport(server: Server) {
|
function startHttpTransport(httpServer: http.Server, serverBackendFactory: ServerBackendFactory) {
|
||||||
const sseSessions = new Map<string, SSEServerTransport>();
|
const sseSessions = new Map();
|
||||||
const streamableSessions = new Map<string, StreamableHTTPServerTransport>();
|
const streamableSessions = new Map();
|
||||||
const httpServer = http.createServer(async (req, res) => {
|
httpServer.on('request', async (req, res) => {
|
||||||
const url = new URL(`http://localhost${req.url}`);
|
const url = new URL(`http://localhost${req.url}`);
|
||||||
if (url.pathname.startsWith('/mcp'))
|
if (url.pathname.startsWith('/sse'))
|
||||||
await handleStreamable(server, req, res, streamableSessions);
|
await handleSSE(serverBackendFactory, req, res, url, sseSessions);
|
||||||
else
|
else
|
||||||
await handleSSE(server, req, res, url, sseSessions);
|
await handleStreamable(serverBackendFactory, req, res, streamableSessions);
|
||||||
});
|
});
|
||||||
const { host, port } = server.config.server;
|
const url = httpAddressToString(httpServer.address());
|
||||||
httpServer.listen(port, host, () => {
|
const message = [
|
||||||
const address = httpServer.address();
|
`Listening on ${url}`,
|
||||||
assert(address, 'Could not bind server socket');
|
'Put this in your client config:',
|
||||||
let url: string;
|
JSON.stringify({
|
||||||
if (typeof address === 'string') {
|
'mcpServers': {
|
||||||
url = address;
|
'playwright': {
|
||||||
} else {
|
'url': `${url}/mcp`
|
||||||
const resolvedPort = address.port;
|
|
||||||
let resolvedHost = address.family === 'IPv4' ? address.address : `[${address.address}]`;
|
|
||||||
if (resolvedHost === '0.0.0.0' || resolvedHost === '[::]')
|
|
||||||
resolvedHost = 'localhost';
|
|
||||||
url = `http://${resolvedHost}:${resolvedPort}`;
|
|
||||||
}
|
|
||||||
const message = [
|
|
||||||
`Listening on ${url}`,
|
|
||||||
'Put this in your client config:',
|
|
||||||
JSON.stringify({
|
|
||||||
'mcpServers': {
|
|
||||||
'playwright': {
|
|
||||||
'url': `${url}/sse`
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}, undefined, 2),
|
}
|
||||||
'If your client supports streamable HTTP, you can use the /mcp endpoint instead.',
|
}, undefined, 2),
|
||||||
].join('\n');
|
'For legacy SSE transport support, you can use the /sse endpoint instead.',
|
||||||
|
].join('\n');
|
||||||
// eslint-disable-next-line no-console
|
// eslint-disable-next-line no-console
|
||||||
console.error(message);
|
console.error(message);
|
||||||
});
|
|
||||||
}
|
}
|
||||||
@@ -14,9 +14,9 @@
|
|||||||
* limitations under the License.
|
* limitations under the License.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import fs from 'node:fs';
|
import fs from 'fs';
|
||||||
import url from 'node:url';
|
import path from 'path';
|
||||||
import path from 'node:path';
|
import url from 'url';
|
||||||
|
|
||||||
const __filename = url.fileURLToPath(import.meta.url);
|
const __filename = url.fileURLToPath(import.meta.url);
|
||||||
export const packageJSON = JSON.parse(fs.readFileSync(path.join(path.dirname(__filename), '..', 'package.json'), 'utf8'));
|
export const packageJSON = JSON.parse(fs.readFileSync(path.join(path.dirname(__filename), '..', 'package.json'), 'utf8'));
|
||||||
|
|||||||
@@ -1,55 +0,0 @@
|
|||||||
/**
|
|
||||||
* Copyright (c) Microsoft Corporation.
|
|
||||||
*
|
|
||||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
|
||||||
* you may not use this file except in compliance with the License.
|
|
||||||
* You may obtain a copy of the License at
|
|
||||||
*
|
|
||||||
* http://www.apache.org/licenses/LICENSE-2.0
|
|
||||||
*
|
|
||||||
* Unless required by applicable law or agreed to in writing, software
|
|
||||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
|
||||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
||||||
* See the License for the specific language governing permissions and
|
|
||||||
* limitations under the License.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import * as playwright from 'playwright';
|
|
||||||
import { callOnPageNoTrace } from './tools/utils.js';
|
|
||||||
|
|
||||||
type PageEx = playwright.Page & {
|
|
||||||
_snapshotForAI: () => Promise<string>;
|
|
||||||
};
|
|
||||||
|
|
||||||
export class PageSnapshot {
|
|
||||||
private _page: playwright.Page;
|
|
||||||
private _text!: string;
|
|
||||||
|
|
||||||
constructor(page: playwright.Page) {
|
|
||||||
this._page = page;
|
|
||||||
}
|
|
||||||
|
|
||||||
static async create(page: playwright.Page): Promise<PageSnapshot> {
|
|
||||||
const snapshot = new PageSnapshot(page);
|
|
||||||
await snapshot._build();
|
|
||||||
return snapshot;
|
|
||||||
}
|
|
||||||
|
|
||||||
text(): string {
|
|
||||||
return this._text;
|
|
||||||
}
|
|
||||||
|
|
||||||
private async _build() {
|
|
||||||
const snapshot = await callOnPageNoTrace(this._page, page => (page as PageEx)._snapshotForAI());
|
|
||||||
this._text = [
|
|
||||||
`- Page Snapshot`,
|
|
||||||
'```yaml',
|
|
||||||
snapshot,
|
|
||||||
'```',
|
|
||||||
].join('\n');
|
|
||||||
}
|
|
||||||
|
|
||||||
refLocator(params: { element: string, ref: string }): playwright.Locator {
|
|
||||||
return this._page.locator(`aria-ref=${params.ref}`).describe(params.element);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -14,14 +14,18 @@
|
|||||||
* limitations under the License.
|
* limitations under the License.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { program } from 'commander';
|
import { program, Option } from 'commander';
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
import { startTraceViewerServer } from 'playwright-core/lib/server';
|
import { startTraceViewerServer } from 'playwright-core/lib/server';
|
||||||
|
|
||||||
import { startHttpTransport, startStdioTransport } from './transport.js';
|
import * as mcpTransport from './mcp/transport.js';
|
||||||
import { resolveCLIConfig } from './config.js';
|
import { commaSeparatedList, resolveCLIConfig, semicolonSeparatedList } from './config.js';
|
||||||
import { Server } from './server.js';
|
|
||||||
import { packageJSON } from './package.js';
|
import { packageJSON } from './package.js';
|
||||||
|
import { createExtensionContextFactory, runWithExtension } from './extension/main.js';
|
||||||
|
import { BrowserServerBackend, FactoryList } from './browserServerBackend.js';
|
||||||
|
import { Context } from './context.js';
|
||||||
|
import { contextFactory } from './browserContextFactory.js';
|
||||||
|
import { runLoopTools } from './loopTools/main.js';
|
||||||
|
|
||||||
program
|
program
|
||||||
.version('Version ' + packageJSON.version)
|
.version('Version ' + packageJSON.version)
|
||||||
@@ -30,8 +34,7 @@ program
|
|||||||
.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('--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('--block-service-workers', 'block service workers')
|
||||||
.option('--browser <browser>', 'browser or chrome channel to use, possible values: chrome, firefox, webkit, msedge.')
|
.option('--browser <browser>', 'browser or chrome channel to use, possible values: chrome, firefox, webkit, msedge.')
|
||||||
.option('--browser-agent <endpoint>', 'Use browser agent (experimental).')
|
.option('--caps <caps>', 'comma-separated list of additional capabilities to enable, possible values: vision, pdf.', commaSeparatedList)
|
||||||
.option('--caps <caps>', 'comma-separated list of capabilities to enable, possible values: tabs, pdf, history, wait, files, install. Default is all.')
|
|
||||||
.option('--cdp-endpoint <endpoint>', 'CDP endpoint to connect to.')
|
.option('--cdp-endpoint <endpoint>', 'CDP endpoint to connect to.')
|
||||||
.option('--config <path>', 'path to the configuration file.')
|
.option('--config <path>', 'path to the configuration file.')
|
||||||
.option('--device <device>', 'device to emulate, for example: "iPhone 15"')
|
.option('--device <device>', 'device to emulate, for example: "iPhone 15"')
|
||||||
@@ -40,27 +43,47 @@ program
|
|||||||
.option('--host <host>', 'host to bind server to. Default is localhost. Use 0.0.0.0 to bind to all interfaces.')
|
.option('--host <host>', 'host to bind server to. Default is localhost. Use 0.0.0.0 to bind to all interfaces.')
|
||||||
.option('--ignore-https-errors', 'ignore https errors')
|
.option('--ignore-https-errors', 'ignore https errors')
|
||||||
.option('--isolated', 'keep the browser profile in memory, do not save it to disk.')
|
.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('--image-responses <mode>', 'whether to send image responses to the client. Can be "allow" or "omit", Defaults to "allow".')
|
||||||
.option('--no-sandbox', 'disable the sandbox for all process types that are normally sandboxed.')
|
.option('--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('--output-dir <path>', 'path to the directory for output files.')
|
||||||
.option('--port <port>', 'port to listen on for SSE transport.')
|
.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-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('--proxy-server <proxy>', 'specify proxy server, for example "http://myproxy:3128" or "socks5://myproxy:8080"')
|
||||||
|
.option('--save-session', 'Whether to save the Playwright MCP session into the output directory.')
|
||||||
.option('--save-trace', 'Whether to save the Playwright Trace of the session into the output directory.')
|
.option('--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('--storage-state <path>', 'path to the storage state file for isolated sessions.')
|
||||||
.option('--user-agent <ua string>', 'specify user agent string')
|
.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('--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('--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)')
|
.addOption(new Option('--extension', 'Connect to a running browser instance (Edge/Chrome only). Requires the "Playwright MCP Bridge" browser extension to be installed.').hideHelp())
|
||||||
|
.addOption(new Option('--connect-tool', 'Allow to switch between different browser connection methods.').hideHelp())
|
||||||
|
.addOption(new Option('--loop-tools', 'Run loop tools').hideHelp())
|
||||||
|
.addOption(new Option('--vision', 'Legacy option, use --caps=vision instead').hideHelp())
|
||||||
.action(async options => {
|
.action(async options => {
|
||||||
const config = await resolveCLIConfig(options);
|
setupExitWatchdog();
|
||||||
const server = new Server(config);
|
|
||||||
server.setupExitWatchdog();
|
|
||||||
|
|
||||||
if (config.server.port !== undefined)
|
if (options.vision) {
|
||||||
startHttpTransport(server);
|
// eslint-disable-next-line no-console
|
||||||
else
|
console.error('The --vision option is deprecated, use --caps=vision instead');
|
||||||
await startStdioTransport(server);
|
options.caps = 'vision';
|
||||||
|
}
|
||||||
|
const config = await resolveCLIConfig(options);
|
||||||
|
|
||||||
|
if (options.extension) {
|
||||||
|
await runWithExtension(config);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (options.loopTools) {
|
||||||
|
await runLoopTools(config);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const browserContextFactory = contextFactory(config);
|
||||||
|
const factories: FactoryList = [browserContextFactory];
|
||||||
|
if (options.connectTool)
|
||||||
|
factories.push(createExtensionContextFactory(config));
|
||||||
|
const serverBackendFactory = () => new BrowserServerBackend(config, factories);
|
||||||
|
await mcpTransport.start(serverBackendFactory, config.server);
|
||||||
|
|
||||||
if (config.saveTrace) {
|
if (config.saveTrace) {
|
||||||
const server = await startTraceViewerServer();
|
const server = await startTraceViewerServer();
|
||||||
@@ -71,8 +94,20 @@ program
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
function semicolonSeparatedList(value: string): string[] {
|
function setupExitWatchdog() {
|
||||||
return value.split(';').map(v => v.trim());
|
let isExiting = false;
|
||||||
|
const handleExit = async () => {
|
||||||
|
if (isExiting)
|
||||||
|
return;
|
||||||
|
isExiting = true;
|
||||||
|
setTimeout(() => process.exit(0), 15000);
|
||||||
|
await Context.disposeAll();
|
||||||
|
process.exit(0);
|
||||||
|
};
|
||||||
|
|
||||||
|
process.stdin.on('close', handleExit);
|
||||||
|
process.on('SIGINT', handleExit);
|
||||||
|
process.on('SIGTERM', handleExit);
|
||||||
}
|
}
|
||||||
|
|
||||||
void program.parseAsync(process.argv);
|
void program.parseAsync(process.argv);
|
||||||
|
|||||||
201
src/response.ts
Normal file
201
src/response.ts
Normal file
@@ -0,0 +1,201 @@
|
|||||||
|
/**
|
||||||
|
* Copyright (c) Microsoft Corporation.
|
||||||
|
*
|
||||||
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
* you may not use this file except in compliance with the License.
|
||||||
|
* You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing, software
|
||||||
|
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
* See the License for the specific language governing permissions and
|
||||||
|
* limitations under the License.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { renderModalStates } from './tab.js';
|
||||||
|
|
||||||
|
import type { Tab, TabSnapshot } from './tab.js';
|
||||||
|
import type { ImageContent, TextContent } from '@modelcontextprotocol/sdk/types.js';
|
||||||
|
import type { Context } from './context.js';
|
||||||
|
|
||||||
|
export class Response {
|
||||||
|
private _result: string[] = [];
|
||||||
|
private _code: string[] = [];
|
||||||
|
private _images: { contentType: string, data: Buffer }[] = [];
|
||||||
|
private _context: Context;
|
||||||
|
private _includeSnapshot = false;
|
||||||
|
private _includeTabs = false;
|
||||||
|
private _tabSnapshot: TabSnapshot | undefined;
|
||||||
|
|
||||||
|
readonly toolName: string;
|
||||||
|
readonly toolArgs: Record<string, any>;
|
||||||
|
private _isError: boolean | undefined;
|
||||||
|
|
||||||
|
constructor(context: Context, toolName: string, toolArgs: Record<string, any>) {
|
||||||
|
this._context = context;
|
||||||
|
this.toolName = toolName;
|
||||||
|
this.toolArgs = toolArgs;
|
||||||
|
}
|
||||||
|
|
||||||
|
addResult(result: string) {
|
||||||
|
this._result.push(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
addError(error: string) {
|
||||||
|
this._result.push(error);
|
||||||
|
this._isError = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
isError() {
|
||||||
|
return this._isError;
|
||||||
|
}
|
||||||
|
|
||||||
|
result() {
|
||||||
|
return this._result.join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
addCode(code: string) {
|
||||||
|
this._code.push(code);
|
||||||
|
}
|
||||||
|
|
||||||
|
code() {
|
||||||
|
return this._code.join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
addImage(image: { contentType: string, data: Buffer }) {
|
||||||
|
this._images.push(image);
|
||||||
|
}
|
||||||
|
|
||||||
|
images() {
|
||||||
|
return this._images;
|
||||||
|
}
|
||||||
|
|
||||||
|
setIncludeSnapshot() {
|
||||||
|
this._includeSnapshot = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
setIncludeTabs() {
|
||||||
|
this._includeTabs = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
async finish() {
|
||||||
|
// All the async snapshotting post-action is happening here.
|
||||||
|
// Everything below should race against modal states.
|
||||||
|
if (this._includeSnapshot && this._context.currentTab())
|
||||||
|
this._tabSnapshot = await this._context.currentTabOrDie().captureSnapshot();
|
||||||
|
for (const tab of this._context.tabs())
|
||||||
|
await tab.updateTitle();
|
||||||
|
}
|
||||||
|
|
||||||
|
tabSnapshot(): TabSnapshot | undefined {
|
||||||
|
return this._tabSnapshot;
|
||||||
|
}
|
||||||
|
|
||||||
|
serialize(): { content: (TextContent | ImageContent)[], isError?: boolean } {
|
||||||
|
const response: string[] = [];
|
||||||
|
|
||||||
|
// Start with command result.
|
||||||
|
if (this._result.length) {
|
||||||
|
response.push('### Result');
|
||||||
|
response.push(this._result.join('\n'));
|
||||||
|
response.push('');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add code if it exists.
|
||||||
|
if (this._code.length) {
|
||||||
|
response.push(`### Ran Playwright code
|
||||||
|
\`\`\`js
|
||||||
|
${this._code.join('\n')}
|
||||||
|
\`\`\``);
|
||||||
|
response.push('');
|
||||||
|
}
|
||||||
|
|
||||||
|
// List browser tabs.
|
||||||
|
if (this._includeSnapshot || this._includeTabs)
|
||||||
|
response.push(...renderTabsMarkdown(this._context.tabs(), this._includeTabs));
|
||||||
|
|
||||||
|
// Add snapshot if provided.
|
||||||
|
if (this._tabSnapshot?.modalStates.length) {
|
||||||
|
response.push(...renderModalStates(this._context, this._tabSnapshot.modalStates));
|
||||||
|
response.push('');
|
||||||
|
} else if (this._tabSnapshot) {
|
||||||
|
response.push(renderTabSnapshot(this._tabSnapshot));
|
||||||
|
response.push('');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Main response part
|
||||||
|
const content: (TextContent | ImageContent)[] = [
|
||||||
|
{ type: 'text', text: response.join('\n') },
|
||||||
|
];
|
||||||
|
|
||||||
|
// Image attachments.
|
||||||
|
if (this._context.config.imageResponses !== 'omit') {
|
||||||
|
for (const image of this._images)
|
||||||
|
content.push({ type: 'image', data: image.data.toString('base64'), mimeType: image.contentType });
|
||||||
|
}
|
||||||
|
|
||||||
|
return { content, isError: this._isError };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderTabSnapshot(tabSnapshot: TabSnapshot): string {
|
||||||
|
const lines: string[] = [];
|
||||||
|
|
||||||
|
if (tabSnapshot.consoleMessages.length) {
|
||||||
|
lines.push(`### New console messages`);
|
||||||
|
for (const message of tabSnapshot.consoleMessages)
|
||||||
|
lines.push(`- ${trim(message.toString(), 100)}`);
|
||||||
|
lines.push('');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (tabSnapshot.downloads.length) {
|
||||||
|
lines.push(`### Downloads`);
|
||||||
|
for (const entry of tabSnapshot.downloads) {
|
||||||
|
if (entry.finished)
|
||||||
|
lines.push(`- Downloaded file ${entry.download.suggestedFilename()} to ${entry.outputFile}`);
|
||||||
|
else
|
||||||
|
lines.push(`- Downloading file ${entry.download.suggestedFilename()} ...`);
|
||||||
|
}
|
||||||
|
lines.push('');
|
||||||
|
}
|
||||||
|
|
||||||
|
lines.push(`### Page state`);
|
||||||
|
lines.push(`- Page URL: ${tabSnapshot.url}`);
|
||||||
|
lines.push(`- Page Title: ${tabSnapshot.title}`);
|
||||||
|
lines.push(`- Page Snapshot:`);
|
||||||
|
lines.push('```yaml');
|
||||||
|
lines.push(tabSnapshot.ariaSnapshot);
|
||||||
|
lines.push('```');
|
||||||
|
|
||||||
|
return lines.join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderTabsMarkdown(tabs: Tab[], force: boolean = false): string[] {
|
||||||
|
if (tabs.length === 1 && !force)
|
||||||
|
return [];
|
||||||
|
|
||||||
|
if (!tabs.length) {
|
||||||
|
return [
|
||||||
|
'### Open tabs',
|
||||||
|
'No open tabs. Use the "browser_navigate" tool to navigate to a page first.',
|
||||||
|
'',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
const lines: string[] = ['### Open tabs'];
|
||||||
|
for (let i = 0; i < tabs.length; i++) {
|
||||||
|
const tab = tabs[i];
|
||||||
|
const current = tab.isCurrentTab() ? ' (current)' : '';
|
||||||
|
lines.push(`- ${i}:${current} [${tab.lastTitle()}] (${tab.page.url()})`);
|
||||||
|
}
|
||||||
|
lines.push('');
|
||||||
|
return lines;
|
||||||
|
}
|
||||||
|
|
||||||
|
function trim(text: string, maxLength: number) {
|
||||||
|
if (text.length <= maxLength)
|
||||||
|
return text;
|
||||||
|
return text.slice(0, maxLength) + '...';
|
||||||
|
}
|
||||||
@@ -1,59 +0,0 @@
|
|||||||
/**
|
|
||||||
* Copyright (c) Microsoft Corporation.
|
|
||||||
*
|
|
||||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
|
||||||
* you may not use this file except in compliance with the License.
|
|
||||||
* You may obtain a copy of the License at
|
|
||||||
*
|
|
||||||
* http://www.apache.org/licenses/LICENSE-2.0
|
|
||||||
*
|
|
||||||
* Unless required by applicable law or agreed to in writing, software
|
|
||||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
|
||||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
||||||
* See the License for the specific language governing permissions and
|
|
||||||
* limitations under the License.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { createConnection } from './connection.js';
|
|
||||||
import { contextFactory } from './browserContextFactory.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';
|
|
||||||
|
|
||||||
export class Server {
|
|
||||||
readonly config: FullConfig;
|
|
||||||
private _connectionList: Connection[] = [];
|
|
||||||
private _browserConfig: FullConfig['browser'];
|
|
||||||
private _contextFactory: BrowserContextFactory;
|
|
||||||
|
|
||||||
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);
|
|
||||||
};
|
|
||||||
|
|
||||||
process.stdin.on('close', handleExit);
|
|
||||||
process.on('SIGINT', handleExit);
|
|
||||||
process.on('SIGTERM', handleExit);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
176
src/sessionLog.ts
Normal file
176
src/sessionLog.ts
Normal file
@@ -0,0 +1,176 @@
|
|||||||
|
/**
|
||||||
|
* Copyright (c) Microsoft Corporation.
|
||||||
|
*
|
||||||
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
* you may not use this file except in compliance with the License.
|
||||||
|
* You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing, software
|
||||||
|
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
* See the License for the specific language governing permissions and
|
||||||
|
* limitations under the License.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import fs from 'fs';
|
||||||
|
import path from 'path';
|
||||||
|
|
||||||
|
import { Response } from './response.js';
|
||||||
|
import { logUnhandledError } from './log.js';
|
||||||
|
import { outputFile } from './config.js';
|
||||||
|
|
||||||
|
import type { FullConfig } from './config.js';
|
||||||
|
import type * as actions from './actions.js';
|
||||||
|
import type { Tab, TabSnapshot } from './tab.js';
|
||||||
|
|
||||||
|
type LogEntry = {
|
||||||
|
timestamp: number;
|
||||||
|
toolCall?: {
|
||||||
|
toolName: string;
|
||||||
|
toolArgs: Record<string, any>;
|
||||||
|
result: string;
|
||||||
|
isError?: boolean;
|
||||||
|
};
|
||||||
|
userAction?: actions.Action;
|
||||||
|
code: string;
|
||||||
|
tabSnapshot?: TabSnapshot;
|
||||||
|
};
|
||||||
|
|
||||||
|
export class SessionLog {
|
||||||
|
private _folder: string;
|
||||||
|
private _file: string;
|
||||||
|
private _ordinal = 0;
|
||||||
|
private _pendingEntries: LogEntry[] = [];
|
||||||
|
private _sessionFileQueue = Promise.resolve();
|
||||||
|
private _flushEntriesTimeout: NodeJS.Timeout | undefined;
|
||||||
|
|
||||||
|
constructor(sessionFolder: string) {
|
||||||
|
this._folder = sessionFolder;
|
||||||
|
this._file = path.join(this._folder, 'session.md');
|
||||||
|
}
|
||||||
|
|
||||||
|
static async create(config: FullConfig, rootPath: string | undefined): Promise<SessionLog> {
|
||||||
|
const sessionFolder = await outputFile(config, rootPath, `session-${Date.now()}`);
|
||||||
|
await fs.promises.mkdir(sessionFolder, { recursive: true });
|
||||||
|
// eslint-disable-next-line no-console
|
||||||
|
console.error(`Session: ${sessionFolder}`);
|
||||||
|
return new SessionLog(sessionFolder);
|
||||||
|
}
|
||||||
|
|
||||||
|
logResponse(response: Response) {
|
||||||
|
const entry: LogEntry = {
|
||||||
|
timestamp: performance.now(),
|
||||||
|
toolCall: {
|
||||||
|
toolName: response.toolName,
|
||||||
|
toolArgs: response.toolArgs,
|
||||||
|
result: response.result(),
|
||||||
|
isError: response.isError(),
|
||||||
|
},
|
||||||
|
code: response.code(),
|
||||||
|
tabSnapshot: response.tabSnapshot(),
|
||||||
|
};
|
||||||
|
this._appendEntry(entry);
|
||||||
|
}
|
||||||
|
|
||||||
|
logUserAction(action: actions.Action, tab: Tab, code: string, isUpdate: boolean) {
|
||||||
|
code = code.trim();
|
||||||
|
if (isUpdate) {
|
||||||
|
const lastEntry = this._pendingEntries[this._pendingEntries.length - 1];
|
||||||
|
if (lastEntry.userAction?.name === action.name) {
|
||||||
|
lastEntry.userAction = action;
|
||||||
|
lastEntry.code = code;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (action.name === 'navigate') {
|
||||||
|
// Already logged at this location.
|
||||||
|
const lastEntry = this._pendingEntries[this._pendingEntries.length - 1];
|
||||||
|
if (lastEntry?.tabSnapshot?.url === action.url)
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const entry: LogEntry = {
|
||||||
|
timestamp: performance.now(),
|
||||||
|
userAction: action,
|
||||||
|
code,
|
||||||
|
tabSnapshot: {
|
||||||
|
url: tab.page.url(),
|
||||||
|
title: '',
|
||||||
|
ariaSnapshot: action.ariaSnapshot || '',
|
||||||
|
modalStates: [],
|
||||||
|
consoleMessages: [],
|
||||||
|
downloads: [],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
this._appendEntry(entry);
|
||||||
|
}
|
||||||
|
|
||||||
|
private _appendEntry(entry: LogEntry) {
|
||||||
|
this._pendingEntries.push(entry);
|
||||||
|
if (this._flushEntriesTimeout)
|
||||||
|
clearTimeout(this._flushEntriesTimeout);
|
||||||
|
this._flushEntriesTimeout = setTimeout(() => this._flushEntries(), 1000);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async _flushEntries() {
|
||||||
|
clearTimeout(this._flushEntriesTimeout);
|
||||||
|
const entries = this._pendingEntries;
|
||||||
|
this._pendingEntries = [];
|
||||||
|
const lines: string[] = [''];
|
||||||
|
|
||||||
|
for (const entry of entries) {
|
||||||
|
const ordinal = (++this._ordinal).toString().padStart(3, '0');
|
||||||
|
if (entry.toolCall) {
|
||||||
|
lines.push(
|
||||||
|
`### Tool call: ${entry.toolCall.toolName}`,
|
||||||
|
`- Args`,
|
||||||
|
'```json',
|
||||||
|
JSON.stringify(entry.toolCall.toolArgs, null, 2),
|
||||||
|
'```',
|
||||||
|
);
|
||||||
|
if (entry.toolCall.result) {
|
||||||
|
lines.push(
|
||||||
|
entry.toolCall.isError ? `- Error` : `- Result`,
|
||||||
|
'```',
|
||||||
|
entry.toolCall.result,
|
||||||
|
'```',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (entry.userAction) {
|
||||||
|
const actionData = { ...entry.userAction } as any;
|
||||||
|
delete actionData.ariaSnapshot;
|
||||||
|
delete actionData.selector;
|
||||||
|
delete actionData.signals;
|
||||||
|
|
||||||
|
lines.push(
|
||||||
|
`### User action: ${entry.userAction.name}`,
|
||||||
|
`- Args`,
|
||||||
|
'```json',
|
||||||
|
JSON.stringify(actionData, null, 2),
|
||||||
|
'```',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (entry.code) {
|
||||||
|
lines.push(
|
||||||
|
`- Code`,
|
||||||
|
'```js',
|
||||||
|
entry.code,
|
||||||
|
'```');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (entry.tabSnapshot) {
|
||||||
|
const fileName = `${ordinal}.snapshot.yml`;
|
||||||
|
fs.promises.writeFile(path.join(this._folder, fileName), entry.tabSnapshot.ariaSnapshot).catch(logUnhandledError);
|
||||||
|
lines.push(`- Snapshot: ${fileName}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
lines.push('', '');
|
||||||
|
}
|
||||||
|
|
||||||
|
this._sessionFileQueue = this._sessionFileQueue.then(() => fs.promises.appendFile(this._file, lines.join('\n')));
|
||||||
|
}
|
||||||
|
}
|
||||||
251
src/tab.ts
251
src/tab.ts
@@ -14,66 +14,151 @@
|
|||||||
* limitations under the License.
|
* limitations under the License.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import { EventEmitter } from 'events';
|
||||||
import * as playwright from 'playwright';
|
import * as playwright from 'playwright';
|
||||||
|
import { callOnPageNoTrace, waitForCompletion } from './tools/utils.js';
|
||||||
import { PageSnapshot } from './pageSnapshot.js';
|
import { logUnhandledError } from './log.js';
|
||||||
|
import { ManualPromise } from './manualPromise.js';
|
||||||
|
import { ModalState } from './tools/tool.js';
|
||||||
|
|
||||||
import type { Context } from './context.js';
|
import type { Context } from './context.js';
|
||||||
import { callOnPageNoTrace } from './tools/utils.js';
|
|
||||||
|
|
||||||
export class Tab {
|
type PageEx = playwright.Page & {
|
||||||
|
_snapshotForAI: () => Promise<string>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const TabEvents = {
|
||||||
|
modalState: 'modalState'
|
||||||
|
};
|
||||||
|
|
||||||
|
export type TabEventsInterface = {
|
||||||
|
[TabEvents.modalState]: [modalState: ModalState];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type TabSnapshot = {
|
||||||
|
url: string;
|
||||||
|
title: string;
|
||||||
|
ariaSnapshot: string;
|
||||||
|
modalStates: ModalState[];
|
||||||
|
consoleMessages: ConsoleMessage[];
|
||||||
|
downloads: { download: playwright.Download, finished: boolean, outputFile: string }[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export class Tab extends EventEmitter<TabEventsInterface> {
|
||||||
readonly context: Context;
|
readonly context: Context;
|
||||||
readonly page: playwright.Page;
|
readonly page: playwright.Page;
|
||||||
private _consoleMessages: playwright.ConsoleMessage[] = [];
|
private _lastTitle = 'about:blank';
|
||||||
|
private _consoleMessages: ConsoleMessage[] = [];
|
||||||
|
private _recentConsoleMessages: ConsoleMessage[] = [];
|
||||||
private _requests: Map<playwright.Request, playwright.Response | null> = new Map();
|
private _requests: Map<playwright.Request, playwright.Response | null> = new Map();
|
||||||
private _snapshot: PageSnapshot | undefined;
|
|
||||||
private _onPageClose: (tab: Tab) => void;
|
private _onPageClose: (tab: Tab) => void;
|
||||||
|
private _modalStates: ModalState[] = [];
|
||||||
|
private _downloads: { download: playwright.Download, finished: boolean, outputFile: string }[] = [];
|
||||||
|
|
||||||
constructor(context: Context, page: playwright.Page, onPageClose: (tab: Tab) => void) {
|
constructor(context: Context, page: playwright.Page, onPageClose: (tab: Tab) => void) {
|
||||||
|
super();
|
||||||
this.context = context;
|
this.context = context;
|
||||||
this.page = page;
|
this.page = page;
|
||||||
this._onPageClose = onPageClose;
|
this._onPageClose = onPageClose;
|
||||||
page.on('console', event => this._consoleMessages.push(event));
|
page.on('console', event => this._handleConsoleMessage(messageToConsoleMessage(event)));
|
||||||
|
page.on('pageerror', error => this._handleConsoleMessage(pageErrorToConsoleMessage(error)));
|
||||||
page.on('request', request => this._requests.set(request, null));
|
page.on('request', request => this._requests.set(request, null));
|
||||||
page.on('response', response => this._requests.set(response.request(), response));
|
page.on('response', response => this._requests.set(response.request(), response));
|
||||||
page.on('close', () => this._onClose());
|
page.on('close', () => this._onClose());
|
||||||
page.on('filechooser', chooser => {
|
page.on('filechooser', chooser => {
|
||||||
this.context.setModalState({
|
this.setModalState({
|
||||||
type: 'fileChooser',
|
type: 'fileChooser',
|
||||||
description: 'File chooser',
|
description: 'File chooser',
|
||||||
fileChooser: chooser,
|
fileChooser: chooser,
|
||||||
}, this);
|
});
|
||||||
});
|
});
|
||||||
page.on('dialog', dialog => this.context.dialogShown(this, dialog));
|
page.on('dialog', dialog => this._dialogShown(dialog));
|
||||||
page.on('download', download => {
|
page.on('download', download => {
|
||||||
void this.context.downloadStarted(this, download);
|
void this._downloadStarted(download);
|
||||||
});
|
});
|
||||||
page.setDefaultNavigationTimeout(60000);
|
page.setDefaultNavigationTimeout(60000);
|
||||||
page.setDefaultTimeout(5000);
|
page.setDefaultTimeout(5000);
|
||||||
|
(page as any)[tabSymbol] = this;
|
||||||
|
}
|
||||||
|
|
||||||
|
static forPage(page: playwright.Page): Tab | undefined {
|
||||||
|
return (page as any)[tabSymbol];
|
||||||
|
}
|
||||||
|
|
||||||
|
modalStates(): ModalState[] {
|
||||||
|
return this._modalStates;
|
||||||
|
}
|
||||||
|
|
||||||
|
setModalState(modalState: ModalState) {
|
||||||
|
this._modalStates.push(modalState);
|
||||||
|
this.emit(TabEvents.modalState, modalState);
|
||||||
|
}
|
||||||
|
|
||||||
|
clearModalState(modalState: ModalState) {
|
||||||
|
this._modalStates = this._modalStates.filter(state => state !== modalState);
|
||||||
|
}
|
||||||
|
|
||||||
|
modalStatesMarkdown(): string[] {
|
||||||
|
return renderModalStates(this.context, this.modalStates());
|
||||||
|
}
|
||||||
|
|
||||||
|
private _dialogShown(dialog: playwright.Dialog) {
|
||||||
|
this.setModalState({
|
||||||
|
type: 'dialog',
|
||||||
|
description: `"${dialog.type()}" dialog with message "${dialog.message()}"`,
|
||||||
|
dialog,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private async _downloadStarted(download: playwright.Download) {
|
||||||
|
const entry = {
|
||||||
|
download,
|
||||||
|
finished: false,
|
||||||
|
outputFile: await this.context.outputFile(download.suggestedFilename())
|
||||||
|
};
|
||||||
|
this._downloads.push(entry);
|
||||||
|
await download.saveAs(entry.outputFile);
|
||||||
|
entry.finished = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
private _clearCollectedArtifacts() {
|
private _clearCollectedArtifacts() {
|
||||||
this._consoleMessages.length = 0;
|
this._consoleMessages.length = 0;
|
||||||
|
this._recentConsoleMessages.length = 0;
|
||||||
this._requests.clear();
|
this._requests.clear();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private _handleConsoleMessage(message: ConsoleMessage) {
|
||||||
|
this._consoleMessages.push(message);
|
||||||
|
this._recentConsoleMessages.push(message);
|
||||||
|
}
|
||||||
|
|
||||||
private _onClose() {
|
private _onClose() {
|
||||||
this._clearCollectedArtifacts();
|
this._clearCollectedArtifacts();
|
||||||
this._onPageClose(this);
|
this._onPageClose(this);
|
||||||
}
|
}
|
||||||
|
|
||||||
async title(): Promise<string> {
|
async updateTitle() {
|
||||||
return await callOnPageNoTrace(this.page, page => page.title());
|
await this._raceAgainstModalStates(async () => {
|
||||||
|
this._lastTitle = await callOnPageNoTrace(this.page, page => page.title());
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
lastTitle(): string {
|
||||||
|
return this._lastTitle;
|
||||||
|
}
|
||||||
|
|
||||||
|
isCurrentTab(): boolean {
|
||||||
|
return this === this.context.currentTab();
|
||||||
}
|
}
|
||||||
|
|
||||||
async waitForLoadState(state: 'load', options?: { timeout?: number }): Promise<void> {
|
async waitForLoadState(state: 'load', options?: { timeout?: number }): Promise<void> {
|
||||||
await callOnPageNoTrace(this.page, page => page.waitForLoadState(state, options).catch(() => {}));
|
await callOnPageNoTrace(this.page, page => page.waitForLoadState(state, options).catch(logUnhandledError));
|
||||||
}
|
}
|
||||||
|
|
||||||
async navigate(url: string) {
|
async navigate(url: string) {
|
||||||
this._clearCollectedArtifacts();
|
this._clearCollectedArtifacts();
|
||||||
|
|
||||||
const downloadEvent = callOnPageNoTrace(this.page, page => page.waitForEvent('download').catch(() => {}));
|
const downloadEvent = callOnPageNoTrace(this.page, page => page.waitForEvent('download').catch(logUnhandledError));
|
||||||
try {
|
try {
|
||||||
await this.page.goto(url, { waitUntil: 'domcontentloaded' });
|
await this.page.goto(url, { waitUntil: 'domcontentloaded' });
|
||||||
} catch (_e: unknown) {
|
} catch (_e: unknown) {
|
||||||
@@ -86,27 +171,20 @@ export class Tab {
|
|||||||
// on chromium, the download event is fired *after* page.goto rejects, so we wait a lil bit
|
// on chromium, the download event is fired *after* page.goto rejects, so we wait a lil bit
|
||||||
const download = await Promise.race([
|
const download = await Promise.race([
|
||||||
downloadEvent,
|
downloadEvent,
|
||||||
new Promise(resolve => setTimeout(resolve, 1000)),
|
new Promise(resolve => setTimeout(resolve, 3000)),
|
||||||
]);
|
]);
|
||||||
if (!download)
|
if (!download)
|
||||||
throw e;
|
throw e;
|
||||||
|
// Make sure other "download" listeners are notified first.
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 500));
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Cap load event to 5 seconds, the page is operational at this point.
|
// Cap load event to 5 seconds, the page is operational at this point.
|
||||||
await this.waitForLoadState('load', { timeout: 5000 });
|
await this.waitForLoadState('load', { timeout: 5000 });
|
||||||
}
|
}
|
||||||
|
|
||||||
hasSnapshot(): boolean {
|
consoleMessages(): ConsoleMessage[] {
|
||||||
return !!this._snapshot;
|
|
||||||
}
|
|
||||||
|
|
||||||
snapshotOrDie(): PageSnapshot {
|
|
||||||
if (!this._snapshot)
|
|
||||||
throw new Error('No snapshot available');
|
|
||||||
return this._snapshot;
|
|
||||||
}
|
|
||||||
|
|
||||||
consoleMessages(): playwright.ConsoleMessage[] {
|
|
||||||
return this._consoleMessages;
|
return this._consoleMessages;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -114,7 +192,122 @@ export class Tab {
|
|||||||
return this._requests;
|
return this._requests;
|
||||||
}
|
}
|
||||||
|
|
||||||
async captureSnapshot() {
|
async captureSnapshot(): Promise<TabSnapshot> {
|
||||||
this._snapshot = await PageSnapshot.create(this.page);
|
let tabSnapshot: TabSnapshot | undefined;
|
||||||
|
const modalStates = await this._raceAgainstModalStates(async () => {
|
||||||
|
const snapshot = await (this.page as PageEx)._snapshotForAI();
|
||||||
|
tabSnapshot = {
|
||||||
|
url: this.page.url(),
|
||||||
|
title: await this.page.title(),
|
||||||
|
ariaSnapshot: snapshot,
|
||||||
|
modalStates: [],
|
||||||
|
consoleMessages: [],
|
||||||
|
downloads: this._downloads,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
if (tabSnapshot) {
|
||||||
|
// Assign console message late so that we did not lose any to modal state.
|
||||||
|
tabSnapshot.consoleMessages = this._recentConsoleMessages;
|
||||||
|
this._recentConsoleMessages = [];
|
||||||
|
}
|
||||||
|
return tabSnapshot ?? {
|
||||||
|
url: this.page.url(),
|
||||||
|
title: '',
|
||||||
|
ariaSnapshot: '',
|
||||||
|
modalStates,
|
||||||
|
consoleMessages: [],
|
||||||
|
downloads: [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private _javaScriptBlocked(): boolean {
|
||||||
|
return this._modalStates.some(state => state.type === 'dialog');
|
||||||
|
}
|
||||||
|
|
||||||
|
private async _raceAgainstModalStates(action: () => Promise<void>): Promise<ModalState[]> {
|
||||||
|
if (this.modalStates().length)
|
||||||
|
return this.modalStates();
|
||||||
|
|
||||||
|
const promise = new ManualPromise<ModalState[]>();
|
||||||
|
const listener = (modalState: ModalState) => promise.resolve([modalState]);
|
||||||
|
this.once(TabEvents.modalState, listener);
|
||||||
|
|
||||||
|
return await Promise.race([
|
||||||
|
action().then(() => {
|
||||||
|
this.off(TabEvents.modalState, listener);
|
||||||
|
return [];
|
||||||
|
}),
|
||||||
|
promise,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
async waitForCompletion(callback: () => Promise<void>) {
|
||||||
|
await this._raceAgainstModalStates(() => waitForCompletion(this, callback));
|
||||||
|
}
|
||||||
|
|
||||||
|
async refLocator(params: { element: string, ref: string }): Promise<playwright.Locator> {
|
||||||
|
return (await this.refLocators([params]))[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
async refLocators(params: { element: string, ref: string }[]): Promise<playwright.Locator[]> {
|
||||||
|
const snapshot = await (this.page as PageEx)._snapshotForAI();
|
||||||
|
return params.map(param => {
|
||||||
|
if (!snapshot.includes(`[ref=${param.ref}]`))
|
||||||
|
throw new Error(`Ref ${param.ref} not found in the current page snapshot. Try capturing new snapshot.`);
|
||||||
|
return this.page.locator(`aria-ref=${param.ref}`).describe(param.element);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async waitForTimeout(time: number) {
|
||||||
|
if (this._javaScriptBlocked()) {
|
||||||
|
await new Promise(f => setTimeout(f, time));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await callOnPageNoTrace(this.page, page => {
|
||||||
|
return page.evaluate(() => new Promise(f => setTimeout(f, 1000)));
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type ConsoleMessage = {
|
||||||
|
type: ReturnType<playwright.ConsoleMessage['type']> | undefined;
|
||||||
|
text: string;
|
||||||
|
toString(): string;
|
||||||
|
};
|
||||||
|
|
||||||
|
function messageToConsoleMessage(message: playwright.ConsoleMessage): ConsoleMessage {
|
||||||
|
return {
|
||||||
|
type: message.type(),
|
||||||
|
text: message.text(),
|
||||||
|
toString: () => `[${message.type().toUpperCase()}] ${message.text()} @ ${message.location().url}:${message.location().lineNumber}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function pageErrorToConsoleMessage(errorOrValue: Error | any): ConsoleMessage {
|
||||||
|
if (errorOrValue instanceof Error) {
|
||||||
|
return {
|
||||||
|
type: undefined,
|
||||||
|
text: errorOrValue.message,
|
||||||
|
toString: () => errorOrValue.stack || errorOrValue.message,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
type: undefined,
|
||||||
|
text: String(errorOrValue),
|
||||||
|
toString: () => String(errorOrValue),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function renderModalStates(context: Context, modalStates: ModalState[]): string[] {
|
||||||
|
const result: string[] = ['### Modal state'];
|
||||||
|
if (modalStates.length === 0)
|
||||||
|
result.push('- There is no modal state present');
|
||||||
|
for (const state of modalStates) {
|
||||||
|
const tool = context.tools.filter(tool => 'clearsModalState' in tool).find(tool => tool.clearsModalState === state.type);
|
||||||
|
result.push(`- [${state.description}]: can be handled by the "${tool?.schema.name}" tool`);
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
const tabSymbol = Symbol('tabSymbol');
|
||||||
|
|||||||
42
src/tools.ts
42
src/tools.ts
@@ -17,6 +17,7 @@
|
|||||||
import common from './tools/common.js';
|
import common from './tools/common.js';
|
||||||
import console from './tools/console.js';
|
import console from './tools/console.js';
|
||||||
import dialogs from './tools/dialogs.js';
|
import dialogs from './tools/dialogs.js';
|
||||||
|
import evaluate from './tools/evaluate.js';
|
||||||
import files from './tools/files.js';
|
import files from './tools/files.js';
|
||||||
import install from './tools/install.js';
|
import install from './tools/install.js';
|
||||||
import keyboard from './tools/keyboard.js';
|
import keyboard from './tools/keyboard.js';
|
||||||
@@ -26,41 +27,30 @@ import pdf from './tools/pdf.js';
|
|||||||
import snapshot from './tools/snapshot.js';
|
import snapshot from './tools/snapshot.js';
|
||||||
import tabs from './tools/tabs.js';
|
import tabs from './tools/tabs.js';
|
||||||
import screenshot from './tools/screenshot.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 wait from './tools/wait.js';
|
||||||
|
import mouse from './tools/mouse.js';
|
||||||
|
|
||||||
import type { Tool } from './tools/tool.js';
|
import type { Tool } from './tools/tool.js';
|
||||||
|
import type { FullConfig } from './config.js';
|
||||||
|
|
||||||
export const snapshotTools: Tool<any>[] = [
|
export const allTools: Tool<any>[] = [
|
||||||
...common(true),
|
...common,
|
||||||
...console,
|
...console,
|
||||||
...dialogs(true),
|
...dialogs,
|
||||||
...files(true),
|
...evaluate,
|
||||||
|
...files,
|
||||||
...install,
|
...install,
|
||||||
...keyboard(true),
|
...keyboard,
|
||||||
...navigate(true),
|
...navigate,
|
||||||
...network,
|
...network,
|
||||||
|
...mouse,
|
||||||
...pdf,
|
...pdf,
|
||||||
...screenshot,
|
...screenshot,
|
||||||
...snapshot,
|
...snapshot,
|
||||||
...tabs(true),
|
...tabs,
|
||||||
...testing,
|
...wait,
|
||||||
...wait(true),
|
|
||||||
];
|
];
|
||||||
|
|
||||||
export const visionTools: Tool<any>[] = [
|
export function filteredTools(config: FullConfig) {
|
||||||
...common(false),
|
return allTools.filter(tool => tool.capability.startsWith('core') || config.capabilities?.includes(tool.capability));
|
||||||
...console,
|
}
|
||||||
...dialogs(false),
|
|
||||||
...files(false),
|
|
||||||
...install,
|
|
||||||
...keyboard(false),
|
|
||||||
...navigate(false),
|
|
||||||
...network,
|
|
||||||
...pdf,
|
|
||||||
...tabs(false),
|
|
||||||
...testing,
|
|
||||||
...vision,
|
|
||||||
...wait(false),
|
|
||||||
];
|
|
||||||
|
|||||||
@@ -15,7 +15,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
import { defineTool, type ToolFactory } from './tool.js';
|
import { defineTabTool, defineTool } from './tool.js';
|
||||||
|
|
||||||
const close = defineTool({
|
const close = defineTool({
|
||||||
capability: 'core',
|
capability: 'core',
|
||||||
@@ -28,17 +28,14 @@ const close = defineTool({
|
|||||||
type: 'readOnly',
|
type: 'readOnly',
|
||||||
},
|
},
|
||||||
|
|
||||||
handle: async context => {
|
handle: async (context, params, response) => {
|
||||||
await context.close();
|
await context.closeBrowserContext();
|
||||||
return {
|
response.setIncludeTabs();
|
||||||
code: [`await page.close()`],
|
response.addCode(`await page.close()`);
|
||||||
captureSnapshot: false,
|
|
||||||
waitForNetwork: false,
|
|
||||||
};
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const resize: ToolFactory = captureSnapshot => defineTool({
|
const resize = defineTabTool({
|
||||||
capability: 'core',
|
capability: 'core',
|
||||||
schema: {
|
schema: {
|
||||||
name: 'browser_resize',
|
name: 'browser_resize',
|
||||||
@@ -51,28 +48,16 @@ const resize: ToolFactory = captureSnapshot => defineTool({
|
|||||||
type: 'readOnly',
|
type: 'readOnly',
|
||||||
},
|
},
|
||||||
|
|
||||||
handle: async (context, params) => {
|
handle: async (tab, params, response) => {
|
||||||
const tab = context.currentTabOrDie();
|
response.addCode(`await page.setViewportSize({ width: ${params.width}, height: ${params.height} });`);
|
||||||
|
|
||||||
const code = [
|
await tab.waitForCompletion(async () => {
|
||||||
`// Resize browser window to ${params.width}x${params.height}`,
|
|
||||||
`await page.setViewportSize({ width: ${params.width}, height: ${params.height} });`
|
|
||||||
];
|
|
||||||
|
|
||||||
const action = async () => {
|
|
||||||
await tab.page.setViewportSize({ width: params.width, height: params.height });
|
await tab.page.setViewportSize({ width: params.width, height: params.height });
|
||||||
};
|
});
|
||||||
|
|
||||||
return {
|
|
||||||
code,
|
|
||||||
action,
|
|
||||||
captureSnapshot,
|
|
||||||
waitForNetwork: true
|
|
||||||
};
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
export default (captureSnapshot: boolean) => [
|
export default [
|
||||||
close,
|
close,
|
||||||
resize(captureSnapshot)
|
resize
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -15,9 +15,9 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
import { defineTool } from './tool.js';
|
import { defineTabTool } from './tool.js';
|
||||||
|
|
||||||
const console = defineTool({
|
const console = defineTabTool({
|
||||||
capability: 'core',
|
capability: 'core',
|
||||||
schema: {
|
schema: {
|
||||||
name: 'browser_console_messages',
|
name: 'browser_console_messages',
|
||||||
@@ -26,19 +26,8 @@ const console = defineTool({
|
|||||||
inputSchema: z.object({}),
|
inputSchema: z.object({}),
|
||||||
type: 'readOnly',
|
type: 'readOnly',
|
||||||
},
|
},
|
||||||
handle: async context => {
|
handle: async (tab, params, response) => {
|
||||||
const messages = context.currentTabOrDie().consoleMessages();
|
tab.consoleMessages().map(message => response.addResult(message.toString()));
|
||||||
const log = messages.map(message => `[${message.type().toUpperCase()}] ${message.text()}`).join('\n');
|
|
||||||
return {
|
|
||||||
code: [`// <internal code to get console messages>`],
|
|
||||||
action: async () => {
|
|
||||||
return {
|
|
||||||
content: [{ type: 'text', text: log }]
|
|
||||||
};
|
|
||||||
},
|
|
||||||
captureSnapshot: false,
|
|
||||||
waitForNetwork: false,
|
|
||||||
};
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -15,9 +15,9 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
import { defineTool, type ToolFactory } from './tool.js';
|
import { defineTabTool } from './tool.js';
|
||||||
|
|
||||||
const handleDialog: ToolFactory = captureSnapshot => defineTool({
|
const handleDialog = defineTabTool({
|
||||||
capability: 'core',
|
capability: 'core',
|
||||||
|
|
||||||
schema: {
|
schema: {
|
||||||
@@ -31,32 +31,25 @@ const handleDialog: ToolFactory = captureSnapshot => defineTool({
|
|||||||
type: 'destructive',
|
type: 'destructive',
|
||||||
},
|
},
|
||||||
|
|
||||||
handle: async (context, params) => {
|
handle: async (tab, params, response) => {
|
||||||
const dialogState = context.modalStates().find(state => state.type === 'dialog');
|
response.setIncludeSnapshot();
|
||||||
|
|
||||||
|
const dialogState = tab.modalStates().find(state => state.type === 'dialog');
|
||||||
if (!dialogState)
|
if (!dialogState)
|
||||||
throw new Error('No dialog visible');
|
throw new Error('No dialog visible');
|
||||||
|
|
||||||
if (params.accept)
|
tab.clearModalState(dialogState);
|
||||||
await dialogState.dialog.accept(params.promptText);
|
await tab.waitForCompletion(async () => {
|
||||||
else
|
if (params.accept)
|
||||||
await dialogState.dialog.dismiss();
|
await dialogState.dialog.accept(params.promptText);
|
||||||
|
else
|
||||||
context.clearModalState(dialogState);
|
await dialogState.dialog.dismiss();
|
||||||
|
});
|
||||||
const code = [
|
|
||||||
`// <internal code to handle "${dialogState.dialog.type()}" dialog>`,
|
|
||||||
];
|
|
||||||
|
|
||||||
return {
|
|
||||||
code,
|
|
||||||
captureSnapshot,
|
|
||||||
waitForNetwork: false,
|
|
||||||
};
|
|
||||||
},
|
},
|
||||||
|
|
||||||
clearsModalState: 'dialog',
|
clearsModalState: 'dialog',
|
||||||
});
|
});
|
||||||
|
|
||||||
export default (captureSnapshot: boolean) => [
|
export default [
|
||||||
handleDialog(captureSnapshot),
|
handleDialog,
|
||||||
];
|
];
|
||||||
|
|||||||
62
src/tools/evaluate.ts
Normal file
62
src/tools/evaluate.ts
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
/**
|
||||||
|
* Copyright (c) Microsoft Corporation.
|
||||||
|
*
|
||||||
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
* you may not use this file except in compliance with the License.
|
||||||
|
* You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing, software
|
||||||
|
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
* See the License for the specific language governing permissions and
|
||||||
|
* limitations under the License.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
import { defineTabTool } from './tool.js';
|
||||||
|
import * as javascript from '../javascript.js';
|
||||||
|
import { generateLocator } from './utils.js';
|
||||||
|
|
||||||
|
import type * as playwright from 'playwright';
|
||||||
|
|
||||||
|
const evaluateSchema = z.object({
|
||||||
|
function: z.string().describe('() => { /* code */ } or (element) => { /* code */ } when element is provided'),
|
||||||
|
element: z.string().optional().describe('Human-readable element description used to obtain permission to interact with the element'),
|
||||||
|
ref: z.string().optional().describe('Exact target element reference from the page snapshot'),
|
||||||
|
});
|
||||||
|
|
||||||
|
const evaluate = defineTabTool({
|
||||||
|
capability: 'core',
|
||||||
|
schema: {
|
||||||
|
name: 'browser_evaluate',
|
||||||
|
title: 'Evaluate JavaScript',
|
||||||
|
description: 'Evaluate JavaScript expression on page or element',
|
||||||
|
inputSchema: evaluateSchema,
|
||||||
|
type: 'destructive',
|
||||||
|
},
|
||||||
|
|
||||||
|
handle: async (tab, params, response) => {
|
||||||
|
response.setIncludeSnapshot();
|
||||||
|
|
||||||
|
let locator: playwright.Locator | undefined;
|
||||||
|
if (params.ref && params.element) {
|
||||||
|
locator = await tab.refLocator({ ref: params.ref, element: params.element });
|
||||||
|
response.addCode(`await page.${await generateLocator(locator)}.evaluate(${javascript.quote(params.function)});`);
|
||||||
|
} else {
|
||||||
|
response.addCode(`await page.evaluate(${javascript.quote(params.function)});`);
|
||||||
|
}
|
||||||
|
|
||||||
|
await tab.waitForCompletion(async () => {
|
||||||
|
const receiver = locator ?? tab.page as any;
|
||||||
|
const result = await receiver._evaluateFunction(params.function);
|
||||||
|
response.addResult(JSON.stringify(result, null, 2) || 'undefined');
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export default [
|
||||||
|
evaluate,
|
||||||
|
];
|
||||||
@@ -15,10 +15,10 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
import { defineTool, type ToolFactory } from './tool.js';
|
import { defineTabTool } from './tool.js';
|
||||||
|
|
||||||
const uploadFile: ToolFactory = captureSnapshot => defineTool({
|
const uploadFile = defineTabTool({
|
||||||
capability: 'files',
|
capability: 'core',
|
||||||
|
|
||||||
schema: {
|
schema: {
|
||||||
name: 'browser_file_upload',
|
name: 'browser_file_upload',
|
||||||
@@ -30,30 +30,23 @@ const uploadFile: ToolFactory = captureSnapshot => defineTool({
|
|||||||
type: 'destructive',
|
type: 'destructive',
|
||||||
},
|
},
|
||||||
|
|
||||||
handle: async (context, params) => {
|
handle: async (tab, params, response) => {
|
||||||
const modalState = context.modalStates().find(state => state.type === 'fileChooser');
|
response.setIncludeSnapshot();
|
||||||
|
|
||||||
|
const modalState = tab.modalStates().find(state => state.type === 'fileChooser');
|
||||||
if (!modalState)
|
if (!modalState)
|
||||||
throw new Error('No file chooser visible');
|
throw new Error('No file chooser visible');
|
||||||
|
|
||||||
const code = [
|
response.addCode(`await fileChooser.setFiles(${JSON.stringify(params.paths)})`);
|
||||||
`// <internal code to chose files ${params.paths.join(', ')}`,
|
|
||||||
];
|
|
||||||
|
|
||||||
const action = async () => {
|
tab.clearModalState(modalState);
|
||||||
|
await tab.waitForCompletion(async () => {
|
||||||
await modalState.fileChooser.setFiles(params.paths);
|
await modalState.fileChooser.setFiles(params.paths);
|
||||||
context.clearModalState(modalState);
|
});
|
||||||
};
|
|
||||||
|
|
||||||
return {
|
|
||||||
code,
|
|
||||||
action,
|
|
||||||
captureSnapshot,
|
|
||||||
waitForNetwork: true,
|
|
||||||
};
|
|
||||||
},
|
},
|
||||||
clearsModalState: 'fileChooser',
|
clearsModalState: 'fileChooser',
|
||||||
});
|
});
|
||||||
|
|
||||||
export default (captureSnapshot: boolean) => [
|
export default [
|
||||||
uploadFile(captureSnapshot),
|
uploadFile,
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -16,14 +16,13 @@
|
|||||||
|
|
||||||
import { fork } from 'child_process';
|
import { fork } from 'child_process';
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
|
import { fileURLToPath } from 'url';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
import { defineTool } from './tool.js';
|
import { defineTool } from './tool.js';
|
||||||
|
|
||||||
import { fileURLToPath } from 'node:url';
|
|
||||||
|
|
||||||
const install = defineTool({
|
const install = defineTool({
|
||||||
capability: 'install',
|
capability: 'core-install',
|
||||||
schema: {
|
schema: {
|
||||||
name: 'browser_install',
|
name: 'browser_install',
|
||||||
title: 'Install the browser specified in the config',
|
title: 'Install the browser specified in the config',
|
||||||
@@ -32,7 +31,7 @@ const install = defineTool({
|
|||||||
type: 'destructive',
|
type: 'destructive',
|
||||||
},
|
},
|
||||||
|
|
||||||
handle: async context => {
|
handle: async (context, params, response) => {
|
||||||
const channel = context.config.browser?.launchOptions?.channel ?? context.config.browser?.browserName ?? 'chrome';
|
const channel = context.config.browser?.launchOptions?.channel ?? context.config.browser?.browserName ?? 'chrome';
|
||||||
const cliUrl = import.meta.resolve('playwright/package.json');
|
const cliUrl = import.meta.resolve('playwright/package.json');
|
||||||
const cliPath = path.join(fileURLToPath(cliUrl), '..', 'cli.js');
|
const cliPath = path.join(fileURLToPath(cliUrl), '..', 'cli.js');
|
||||||
@@ -50,11 +49,7 @@ const install = defineTool({
|
|||||||
reject(new Error(`Failed to install browser: ${output.join('')}`));
|
reject(new Error(`Failed to install browser: ${output.join('')}`));
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
return {
|
response.setIncludeTabs();
|
||||||
code: [`// Browser ${channel} installed`],
|
|
||||||
captureSnapshot: false,
|
|
||||||
waitForNetwork: false,
|
|
||||||
};
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -15,9 +15,13 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
import { defineTool, type ToolFactory } from './tool.js';
|
|
||||||
|
|
||||||
const pressKey: ToolFactory = captureSnapshot => defineTool({
|
import { defineTabTool } from './tool.js';
|
||||||
|
import { elementSchema } from './snapshot.js';
|
||||||
|
import { generateLocator } from './utils.js';
|
||||||
|
import * as javascript from '../javascript.js';
|
||||||
|
|
||||||
|
const pressKey = defineTabTool({
|
||||||
capability: 'core',
|
capability: 'core',
|
||||||
|
|
||||||
schema: {
|
schema: {
|
||||||
@@ -30,25 +34,56 @@ const pressKey: ToolFactory = captureSnapshot => defineTool({
|
|||||||
type: 'destructive',
|
type: 'destructive',
|
||||||
},
|
},
|
||||||
|
|
||||||
handle: async (context, params) => {
|
handle: async (tab, params, response) => {
|
||||||
const tab = context.currentTabOrDie();
|
response.setIncludeSnapshot();
|
||||||
|
response.addCode(`// Press ${params.key}`);
|
||||||
|
response.addCode(`await page.keyboard.press('${params.key}');`);
|
||||||
|
|
||||||
const code = [
|
await tab.waitForCompletion(async () => {
|
||||||
`// Press ${params.key}`,
|
await tab.page.keyboard.press(params.key);
|
||||||
`await page.keyboard.press('${params.key}');`,
|
});
|
||||||
];
|
|
||||||
|
|
||||||
const action = () => tab.page.keyboard.press(params.key);
|
|
||||||
|
|
||||||
return {
|
|
||||||
code,
|
|
||||||
action,
|
|
||||||
captureSnapshot,
|
|
||||||
waitForNetwork: true
|
|
||||||
};
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
export default (captureSnapshot: boolean) => [
|
const typeSchema = elementSchema.extend({
|
||||||
pressKey(captureSnapshot),
|
text: z.string().describe('Text to type into the element'),
|
||||||
|
submit: z.boolean().optional().describe('Whether to submit entered text (press Enter after)'),
|
||||||
|
slowly: z.boolean().optional().describe('Whether to type one character at a time. Useful for triggering key handlers in the page. By default entire text is filled in at once.'),
|
||||||
|
});
|
||||||
|
|
||||||
|
const type = defineTabTool({
|
||||||
|
capability: 'core',
|
||||||
|
schema: {
|
||||||
|
name: 'browser_type',
|
||||||
|
title: 'Type text',
|
||||||
|
description: 'Type text into editable element',
|
||||||
|
inputSchema: typeSchema,
|
||||||
|
type: 'destructive',
|
||||||
|
},
|
||||||
|
|
||||||
|
handle: async (tab, params, response) => {
|
||||||
|
const locator = await tab.refLocator(params);
|
||||||
|
|
||||||
|
await tab.waitForCompletion(async () => {
|
||||||
|
if (params.slowly) {
|
||||||
|
response.setIncludeSnapshot();
|
||||||
|
response.addCode(`await page.${await generateLocator(locator)}.pressSequentially(${javascript.quote(params.text)});`);
|
||||||
|
await locator.pressSequentially(params.text);
|
||||||
|
} else {
|
||||||
|
response.addCode(`await page.${await generateLocator(locator)}.fill(${javascript.quote(params.text)});`);
|
||||||
|
await locator.fill(params.text);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (params.submit) {
|
||||||
|
response.setIncludeSnapshot();
|
||||||
|
response.addCode(`await page.${await generateLocator(locator)}.press('Enter');`);
|
||||||
|
await locator.press('Enter');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export default [
|
||||||
|
pressKey,
|
||||||
|
type,
|
||||||
];
|
];
|
||||||
|
|||||||
113
src/tools/mouse.ts
Normal file
113
src/tools/mouse.ts
Normal file
@@ -0,0 +1,113 @@
|
|||||||
|
/**
|
||||||
|
* Copyright (c) Microsoft Corporation.
|
||||||
|
*
|
||||||
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
* you may not use this file except in compliance with the License.
|
||||||
|
* You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing, software
|
||||||
|
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
* See the License for the specific language governing permissions and
|
||||||
|
* limitations under the License.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { z } from 'zod';
|
||||||
|
import { defineTabTool } from './tool.js';
|
||||||
|
|
||||||
|
const elementSchema = z.object({
|
||||||
|
element: z.string().describe('Human-readable element description used to obtain permission to interact with the element'),
|
||||||
|
});
|
||||||
|
|
||||||
|
const mouseMove = defineTabTool({
|
||||||
|
capability: 'vision',
|
||||||
|
schema: {
|
||||||
|
name: 'browser_mouse_move_xy',
|
||||||
|
title: 'Move mouse',
|
||||||
|
description: 'Move mouse to a given position',
|
||||||
|
inputSchema: elementSchema.extend({
|
||||||
|
x: z.number().describe('X coordinate'),
|
||||||
|
y: z.number().describe('Y coordinate'),
|
||||||
|
}),
|
||||||
|
type: 'readOnly',
|
||||||
|
},
|
||||||
|
|
||||||
|
handle: async (tab, params, response) => {
|
||||||
|
response.addCode(`// Move mouse to (${params.x}, ${params.y})`);
|
||||||
|
response.addCode(`await page.mouse.move(${params.x}, ${params.y});`);
|
||||||
|
|
||||||
|
await tab.waitForCompletion(async () => {
|
||||||
|
await tab.page.mouse.move(params.x, params.y);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const mouseClick = defineTabTool({
|
||||||
|
capability: 'vision',
|
||||||
|
schema: {
|
||||||
|
name: 'browser_mouse_click_xy',
|
||||||
|
title: 'Click',
|
||||||
|
description: 'Click left mouse button at a given position',
|
||||||
|
inputSchema: elementSchema.extend({
|
||||||
|
x: z.number().describe('X coordinate'),
|
||||||
|
y: z.number().describe('Y coordinate'),
|
||||||
|
}),
|
||||||
|
type: 'destructive',
|
||||||
|
},
|
||||||
|
|
||||||
|
handle: async (tab, params, response) => {
|
||||||
|
response.setIncludeSnapshot();
|
||||||
|
|
||||||
|
response.addCode(`// Click mouse at coordinates (${params.x}, ${params.y})`);
|
||||||
|
response.addCode(`await page.mouse.move(${params.x}, ${params.y});`);
|
||||||
|
response.addCode(`await page.mouse.down();`);
|
||||||
|
response.addCode(`await page.mouse.up();`);
|
||||||
|
|
||||||
|
await tab.waitForCompletion(async () => {
|
||||||
|
await tab.page.mouse.move(params.x, params.y);
|
||||||
|
await tab.page.mouse.down();
|
||||||
|
await tab.page.mouse.up();
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const mouseDrag = defineTabTool({
|
||||||
|
capability: 'vision',
|
||||||
|
schema: {
|
||||||
|
name: 'browser_mouse_drag_xy',
|
||||||
|
title: 'Drag mouse',
|
||||||
|
description: 'Drag left mouse button to a given position',
|
||||||
|
inputSchema: elementSchema.extend({
|
||||||
|
startX: z.number().describe('Start X coordinate'),
|
||||||
|
startY: z.number().describe('Start Y coordinate'),
|
||||||
|
endX: z.number().describe('End X coordinate'),
|
||||||
|
endY: z.number().describe('End Y coordinate'),
|
||||||
|
}),
|
||||||
|
type: 'destructive',
|
||||||
|
},
|
||||||
|
|
||||||
|
handle: async (tab, params, response) => {
|
||||||
|
response.setIncludeSnapshot();
|
||||||
|
|
||||||
|
response.addCode(`// Drag mouse from (${params.startX}, ${params.startY}) to (${params.endX}, ${params.endY})`);
|
||||||
|
response.addCode(`await page.mouse.move(${params.startX}, ${params.startY});`);
|
||||||
|
response.addCode(`await page.mouse.down();`);
|
||||||
|
response.addCode(`await page.mouse.move(${params.endX}, ${params.endY});`);
|
||||||
|
response.addCode(`await page.mouse.up();`);
|
||||||
|
|
||||||
|
await tab.waitForCompletion(async () => {
|
||||||
|
await tab.page.mouse.move(params.startX, params.startY);
|
||||||
|
await tab.page.mouse.down();
|
||||||
|
await tab.page.mouse.move(params.endX, params.endY);
|
||||||
|
await tab.page.mouse.up();
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export default [
|
||||||
|
mouseMove,
|
||||||
|
mouseClick,
|
||||||
|
mouseDrag,
|
||||||
|
];
|
||||||
@@ -15,9 +15,9 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
import { defineTool, type ToolFactory } from './tool.js';
|
import { defineTool, defineTabTool } from './tool.js';
|
||||||
|
|
||||||
const navigate: ToolFactory = captureSnapshot => defineTool({
|
const navigate = defineTool({
|
||||||
capability: 'core',
|
capability: 'core',
|
||||||
|
|
||||||
schema: {
|
schema: {
|
||||||
@@ -30,25 +30,17 @@ const navigate: ToolFactory = captureSnapshot => defineTool({
|
|||||||
type: 'destructive',
|
type: 'destructive',
|
||||||
},
|
},
|
||||||
|
|
||||||
handle: async (context, params) => {
|
handle: async (context, params, response) => {
|
||||||
const tab = await context.ensureTab();
|
const tab = await context.ensureTab();
|
||||||
await tab.navigate(params.url);
|
await tab.navigate(params.url);
|
||||||
|
|
||||||
const code = [
|
response.setIncludeSnapshot();
|
||||||
`// Navigate to ${params.url}`,
|
response.addCode(`await page.goto('${params.url}');`);
|
||||||
`await page.goto('${params.url}');`,
|
|
||||||
];
|
|
||||||
|
|
||||||
return {
|
|
||||||
code,
|
|
||||||
captureSnapshot,
|
|
||||||
waitForNetwork: false,
|
|
||||||
};
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const goBack: ToolFactory = captureSnapshot => defineTool({
|
const goBack = defineTabTool({
|
||||||
capability: 'history',
|
capability: 'core',
|
||||||
schema: {
|
schema: {
|
||||||
name: 'browser_navigate_back',
|
name: 'browser_navigate_back',
|
||||||
title: 'Go back',
|
title: 'Go back',
|
||||||
@@ -57,24 +49,15 @@ const goBack: ToolFactory = captureSnapshot => defineTool({
|
|||||||
type: 'readOnly',
|
type: 'readOnly',
|
||||||
},
|
},
|
||||||
|
|
||||||
handle: async context => {
|
handle: async (tab, params, response) => {
|
||||||
const tab = await context.ensureTab();
|
|
||||||
await tab.page.goBack();
|
await tab.page.goBack();
|
||||||
const code = [
|
response.setIncludeSnapshot();
|
||||||
`// Navigate back`,
|
response.addCode(`await page.goBack();`);
|
||||||
`await page.goBack();`,
|
|
||||||
];
|
|
||||||
|
|
||||||
return {
|
|
||||||
code,
|
|
||||||
captureSnapshot,
|
|
||||||
waitForNetwork: false,
|
|
||||||
};
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const goForward: ToolFactory = captureSnapshot => defineTool({
|
const goForward = defineTabTool({
|
||||||
capability: 'history',
|
capability: 'core',
|
||||||
schema: {
|
schema: {
|
||||||
name: 'browser_navigate_forward',
|
name: 'browser_navigate_forward',
|
||||||
title: 'Go forward',
|
title: 'Go forward',
|
||||||
@@ -82,23 +65,15 @@ const goForward: ToolFactory = captureSnapshot => defineTool({
|
|||||||
inputSchema: z.object({}),
|
inputSchema: z.object({}),
|
||||||
type: 'readOnly',
|
type: 'readOnly',
|
||||||
},
|
},
|
||||||
handle: async context => {
|
handle: async (tab, params, response) => {
|
||||||
const tab = context.currentTabOrDie();
|
|
||||||
await tab.page.goForward();
|
await tab.page.goForward();
|
||||||
const code = [
|
response.setIncludeSnapshot();
|
||||||
`// Navigate forward`,
|
response.addCode(`await page.goForward();`);
|
||||||
`await page.goForward();`,
|
|
||||||
];
|
|
||||||
return {
|
|
||||||
code,
|
|
||||||
captureSnapshot,
|
|
||||||
waitForNetwork: false,
|
|
||||||
};
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
export default (captureSnapshot: boolean) => [
|
export default [
|
||||||
navigate(captureSnapshot),
|
navigate,
|
||||||
goBack(captureSnapshot),
|
goBack,
|
||||||
goForward(captureSnapshot),
|
goForward,
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -15,11 +15,11 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
import { defineTool } from './tool.js';
|
import { defineTabTool } from './tool.js';
|
||||||
|
|
||||||
import type * as playwright from 'playwright';
|
import type * as playwright from 'playwright';
|
||||||
|
|
||||||
const requests = defineTool({
|
const requests = defineTabTool({
|
||||||
capability: 'core',
|
capability: 'core',
|
||||||
|
|
||||||
schema: {
|
schema: {
|
||||||
@@ -30,19 +30,9 @@ const requests = defineTool({
|
|||||||
type: 'readOnly',
|
type: 'readOnly',
|
||||||
},
|
},
|
||||||
|
|
||||||
handle: async context => {
|
handle: async (tab, params, response) => {
|
||||||
const requests = context.currentTabOrDie().requests();
|
const requests = tab.requests();
|
||||||
const log = [...requests.entries()].map(([request, response]) => renderRequest(request, response)).join('\n');
|
[...requests.entries()].forEach(([req, res]) => response.addResult(renderRequest(req, res)));
|
||||||
return {
|
|
||||||
code: [`// <internal code to list network requests>`],
|
|
||||||
action: async () => {
|
|
||||||
return {
|
|
||||||
content: [{ type: 'text', text: log }]
|
|
||||||
};
|
|
||||||
},
|
|
||||||
captureSnapshot: false,
|
|
||||||
waitForNetwork: false,
|
|
||||||
};
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -15,16 +15,15 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
import { defineTool } from './tool.js';
|
import { defineTabTool } from './tool.js';
|
||||||
|
|
||||||
import * as javascript from '../javascript.js';
|
import * as javascript from '../javascript.js';
|
||||||
import { outputFile } from '../config.js';
|
|
||||||
|
|
||||||
const pdfSchema = z.object({
|
const pdfSchema = z.object({
|
||||||
filename: z.string().optional().describe('File name to save the pdf to. Defaults to `page-{timestamp}.pdf` if not specified.'),
|
filename: z.string().optional().describe('File name to save the pdf to. Defaults to `page-{timestamp}.pdf` if not specified.'),
|
||||||
});
|
});
|
||||||
|
|
||||||
const pdf = defineTool({
|
const pdf = defineTabTool({
|
||||||
capability: 'pdf',
|
capability: 'pdf',
|
||||||
|
|
||||||
schema: {
|
schema: {
|
||||||
@@ -35,21 +34,11 @@ const pdf = defineTool({
|
|||||||
type: 'readOnly',
|
type: 'readOnly',
|
||||||
},
|
},
|
||||||
|
|
||||||
handle: async (context, params) => {
|
handle: async (tab, params, response) => {
|
||||||
const tab = context.currentTabOrDie();
|
const fileName = await tab.context.outputFile(params.filename ?? `page-${new Date().toISOString()}.pdf`);
|
||||||
const fileName = await outputFile(context.config, params.filename ?? `page-${new Date().toISOString()}.pdf`);
|
response.addCode(`await page.pdf(${javascript.formatObject({ path: fileName })});`);
|
||||||
|
response.addResult(`Saved page as ${fileName}`);
|
||||||
const code = [
|
await tab.page.pdf({ path: fileName });
|
||||||
`// Save page as ${fileName}`,
|
|
||||||
`await page.pdf(${javascript.formatObject({ path: fileName })});`,
|
|
||||||
];
|
|
||||||
|
|
||||||
return {
|
|
||||||
code,
|
|
||||||
action: async () => tab.page.pdf({ path: fileName }).then(() => {}),
|
|
||||||
captureSnapshot: false,
|
|
||||||
waitForNetwork: false,
|
|
||||||
};
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -16,26 +16,31 @@
|
|||||||
|
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
|
||||||
import { defineTool } from './tool.js';
|
import { defineTabTool } from './tool.js';
|
||||||
import * as javascript from '../javascript.js';
|
import * as javascript from '../javascript.js';
|
||||||
import { outputFile } from '../config.js';
|
|
||||||
import { generateLocator } from './utils.js';
|
import { generateLocator } from './utils.js';
|
||||||
|
|
||||||
import type * as playwright from 'playwright';
|
import type * as playwright from 'playwright';
|
||||||
|
|
||||||
const screenshotSchema = z.object({
|
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.'),
|
type: z.enum(['png', 'jpeg']).default('png').describe('Image format for the screenshot. Default is png.'),
|
||||||
filename: z.string().optional().describe('File name to save the screenshot to. Defaults to `page-{timestamp}.{png|jpeg}` if not specified.'),
|
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.'),
|
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.'),
|
ref: z.string().optional().describe('Exact target element reference from the page snapshot. If not provided, the screenshot will be taken of viewport. If ref is provided, element must be provided too.'),
|
||||||
|
fullPage: z.boolean().optional().describe('When true, takes a screenshot of the full scrollable page, instead of the currently visible viewport. Cannot be used with element screenshots.'),
|
||||||
}).refine(data => {
|
}).refine(data => {
|
||||||
return !!data.element === !!data.ref;
|
return !!data.element === !!data.ref;
|
||||||
}, {
|
}, {
|
||||||
message: 'Both element and ref must be provided or neither.',
|
message: 'Both element and ref must be provided or neither.',
|
||||||
path: ['ref', 'element']
|
path: ['ref', 'element']
|
||||||
|
}).refine(data => {
|
||||||
|
return !(data.fullPage && (data.element || data.ref));
|
||||||
|
}, {
|
||||||
|
message: 'fullPage cannot be used with element screenshots.',
|
||||||
|
path: ['fullPage']
|
||||||
});
|
});
|
||||||
|
|
||||||
const screenshot = defineTool({
|
const screenshot = defineTabTool({
|
||||||
capability: 'core',
|
capability: 'core',
|
||||||
schema: {
|
schema: {
|
||||||
name: 'browser_take_screenshot',
|
name: 'browser_take_screenshot',
|
||||||
@@ -45,43 +50,40 @@ const screenshot = defineTool({
|
|||||||
type: 'readOnly',
|
type: 'readOnly',
|
||||||
},
|
},
|
||||||
|
|
||||||
handle: async (context, params) => {
|
handle: async (tab, params, response) => {
|
||||||
const tab = context.currentTabOrDie();
|
const fileType = params.type || 'png';
|
||||||
const snapshot = tab.snapshotOrDie();
|
const fileName = await tab.context.outputFile(params.filename ?? `page-${new Date().toISOString()}.${fileType}`);
|
||||||
const fileType = params.raw ? 'png' : 'jpeg';
|
const options: playwright.PageScreenshotOptions = {
|
||||||
const fileName = await outputFile(context.config, params.filename ?? `page-${new Date().toISOString()}.${fileType}`);
|
type: fileType,
|
||||||
const options: playwright.PageScreenshotOptions = { type: fileType, quality: fileType === 'png' ? undefined : 50, scale: 'css', path: fileName };
|
quality: fileType === 'png' ? undefined : 90,
|
||||||
|
scale: 'css',
|
||||||
|
path: fileName,
|
||||||
|
...(params.fullPage !== undefined && { fullPage: params.fullPage })
|
||||||
|
};
|
||||||
const isElementScreenshot = params.element && params.ref;
|
const isElementScreenshot = params.element && params.ref;
|
||||||
|
|
||||||
const code = [
|
const screenshotTarget = isElementScreenshot ? params.element : (params.fullPage ? 'full page' : 'viewport');
|
||||||
`// Screenshot ${isElementScreenshot ? params.element : 'viewport'} and save it as ${fileName}`,
|
response.addCode(`// Screenshot ${screenshotTarget} and save it as ${fileName}`);
|
||||||
];
|
|
||||||
|
|
||||||
const locator = params.ref ? snapshot.refLocator({ element: params.element || '', ref: params.ref }) : null;
|
// Only get snapshot when element screenshot is needed
|
||||||
|
const locator = params.ref ? await tab.refLocator({ element: params.element || '', ref: params.ref }) : null;
|
||||||
|
|
||||||
if (locator)
|
if (locator)
|
||||||
code.push(`await page.${await generateLocator(locator)}.screenshot(${javascript.formatObject(options)});`);
|
response.addCode(`await page.${await generateLocator(locator)}.screenshot(${javascript.formatObject(options)});`);
|
||||||
else
|
else
|
||||||
code.push(`await page.screenshot(${javascript.formatObject(options)});`);
|
response.addCode(`await page.screenshot(${javascript.formatObject(options)});`);
|
||||||
|
|
||||||
const includeBase64 = context.clientSupportsImages();
|
const buffer = locator ? await locator.screenshot(options) : await tab.page.screenshot(options);
|
||||||
const action = async () => {
|
response.addResult(`Took the ${screenshotTarget} screenshot and saved it as ${fileName}`);
|
||||||
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 {
|
// https://github.com/microsoft/playwright-mcp/issues/817
|
||||||
code,
|
// Never return large images to LLM, saving them to the file system is enough.
|
||||||
action,
|
if (!params.fullPage) {
|
||||||
captureSnapshot: true,
|
response.addImage({
|
||||||
waitForNetwork: false,
|
contentType: fileType === 'png' ? 'image/png' : 'image/jpeg',
|
||||||
};
|
data: buffer
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -16,7 +16,7 @@
|
|||||||
|
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
|
||||||
import { defineTool } from './tool.js';
|
import { defineTabTool, defineTool } from './tool.js';
|
||||||
import * as javascript from '../javascript.js';
|
import * as javascript from '../javascript.js';
|
||||||
import { generateLocator } from './utils.js';
|
import { generateLocator } from './utils.js';
|
||||||
|
|
||||||
@@ -30,51 +30,55 @@ const snapshot = defineTool({
|
|||||||
type: 'readOnly',
|
type: 'readOnly',
|
||||||
},
|
},
|
||||||
|
|
||||||
handle: async context => {
|
handle: async (context, params, response) => {
|
||||||
await context.ensureTab();
|
await context.ensureTab();
|
||||||
|
response.setIncludeSnapshot();
|
||||||
return {
|
|
||||||
code: [`// <internal code to capture accessibility snapshot>`],
|
|
||||||
captureSnapshot: true,
|
|
||||||
waitForNetwork: false,
|
|
||||||
};
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const elementSchema = z.object({
|
export const elementSchema = z.object({
|
||||||
element: z.string().describe('Human-readable element description used to obtain permission to interact with the element'),
|
element: z.string().describe('Human-readable element description used to obtain permission to interact with the element'),
|
||||||
ref: z.string().describe('Exact target element reference from the page snapshot'),
|
ref: z.string().describe('Exact target element reference from the page snapshot'),
|
||||||
});
|
});
|
||||||
|
|
||||||
const click = defineTool({
|
const clickSchema = elementSchema.extend({
|
||||||
|
doubleClick: z.boolean().optional().describe('Whether to perform a double click instead of a single click'),
|
||||||
|
button: z.enum(['left', 'right', 'middle']).optional().describe('Button to click, defaults to left'),
|
||||||
|
});
|
||||||
|
|
||||||
|
const click = defineTabTool({
|
||||||
capability: 'core',
|
capability: 'core',
|
||||||
schema: {
|
schema: {
|
||||||
name: 'browser_click',
|
name: 'browser_click',
|
||||||
title: 'Click',
|
title: 'Click',
|
||||||
description: 'Perform click on a web page',
|
description: 'Perform click on a web page',
|
||||||
inputSchema: elementSchema,
|
inputSchema: clickSchema,
|
||||||
type: 'destructive',
|
type: 'destructive',
|
||||||
},
|
},
|
||||||
|
|
||||||
handle: async (context, params) => {
|
handle: async (tab, params, response) => {
|
||||||
const tab = context.currentTabOrDie();
|
response.setIncludeSnapshot();
|
||||||
const locator = tab.snapshotOrDie().refLocator(params);
|
|
||||||
|
|
||||||
const code = [
|
const locator = await tab.refLocator(params);
|
||||||
`// Click ${params.element}`,
|
const button = params.button;
|
||||||
`await page.${await generateLocator(locator)}.click();`
|
const buttonAttr = button ? `{ button: '${button}' }` : '';
|
||||||
];
|
|
||||||
|
|
||||||
return {
|
if (params.doubleClick)
|
||||||
code,
|
response.addCode(`await page.${await generateLocator(locator)}.dblclick(${buttonAttr});`);
|
||||||
action: () => locator.click(),
|
else
|
||||||
captureSnapshot: true,
|
response.addCode(`await page.${await generateLocator(locator)}.click(${buttonAttr});`);
|
||||||
waitForNetwork: true,
|
|
||||||
};
|
|
||||||
|
await tab.waitForCompletion(async () => {
|
||||||
|
if (params.doubleClick)
|
||||||
|
await locator.dblclick({ button });
|
||||||
|
else
|
||||||
|
await locator.click({ button });
|
||||||
|
});
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const drag = defineTool({
|
const drag = defineTabTool({
|
||||||
capability: 'core',
|
capability: 'core',
|
||||||
schema: {
|
schema: {
|
||||||
name: 'browser_drag',
|
name: 'browser_drag',
|
||||||
@@ -89,26 +93,23 @@ const drag = defineTool({
|
|||||||
type: 'destructive',
|
type: 'destructive',
|
||||||
},
|
},
|
||||||
|
|
||||||
handle: async (context, params) => {
|
handle: async (tab, params, response) => {
|
||||||
const snapshot = context.currentTabOrDie().snapshotOrDie();
|
response.setIncludeSnapshot();
|
||||||
const startLocator = snapshot.refLocator({ ref: params.startRef, element: params.startElement });
|
|
||||||
const endLocator = snapshot.refLocator({ ref: params.endRef, element: params.endElement });
|
|
||||||
|
|
||||||
const code = [
|
const [startLocator, endLocator] = await tab.refLocators([
|
||||||
`// Drag ${params.startElement} to ${params.endElement}`,
|
{ ref: params.startRef, element: params.startElement },
|
||||||
`await page.${await generateLocator(startLocator)}.dragTo(page.${await generateLocator(endLocator)});`
|
{ ref: params.endRef, element: params.endElement },
|
||||||
];
|
]);
|
||||||
|
|
||||||
return {
|
await tab.waitForCompletion(async () => {
|
||||||
code,
|
await startLocator.dragTo(endLocator);
|
||||||
action: () => startLocator.dragTo(endLocator),
|
});
|
||||||
captureSnapshot: true,
|
|
||||||
waitForNetwork: true,
|
response.addCode(`await page.${await generateLocator(startLocator)}.dragTo(page.${await generateLocator(endLocator)});`);
|
||||||
};
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const hover = defineTool({
|
const hover = defineTabTool({
|
||||||
capability: 'core',
|
capability: 'core',
|
||||||
schema: {
|
schema: {
|
||||||
name: 'browser_hover',
|
name: 'browser_hover',
|
||||||
@@ -118,69 +119,15 @@ const hover = defineTool({
|
|||||||
type: 'readOnly',
|
type: 'readOnly',
|
||||||
},
|
},
|
||||||
|
|
||||||
handle: async (context, params) => {
|
handle: async (tab, params, response) => {
|
||||||
const snapshot = context.currentTabOrDie().snapshotOrDie();
|
response.setIncludeSnapshot();
|
||||||
const locator = snapshot.refLocator(params);
|
|
||||||
|
|
||||||
const code = [
|
const locator = await tab.refLocator(params);
|
||||||
`// Hover over ${params.element}`,
|
response.addCode(`await page.${await generateLocator(locator)}.hover();`);
|
||||||
`await page.${await generateLocator(locator)}.hover();`
|
|
||||||
];
|
|
||||||
|
|
||||||
return {
|
await tab.waitForCompletion(async () => {
|
||||||
code,
|
await locator.hover();
|
||||||
action: () => locator.hover(),
|
});
|
||||||
captureSnapshot: true,
|
|
||||||
waitForNetwork: true,
|
|
||||||
};
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const typeSchema = elementSchema.extend({
|
|
||||||
text: z.string().describe('Text to type into the element'),
|
|
||||||
submit: z.boolean().optional().describe('Whether to submit entered text (press Enter after)'),
|
|
||||||
slowly: z.boolean().optional().describe('Whether to type one character at a time. Useful for triggering key handlers in the page. By default entire text is filled in at once.'),
|
|
||||||
});
|
|
||||||
|
|
||||||
const type = 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);
|
|
||||||
|
|
||||||
const code: string[] = [];
|
|
||||||
const steps: (() => Promise<void>)[] = [];
|
|
||||||
|
|
||||||
if (params.slowly) {
|
|
||||||
code.push(`// Press "${params.text}" sequentially into "${params.element}"`);
|
|
||||||
code.push(`await page.${await generateLocator(locator)}.pressSequentially(${javascript.quote(params.text)});`);
|
|
||||||
steps.push(() => locator.pressSequentially(params.text));
|
|
||||||
} else {
|
|
||||||
code.push(`// Fill "${params.text}" into "${params.element}"`);
|
|
||||||
code.push(`await page.${await generateLocator(locator)}.fill(${javascript.quote(params.text)});`);
|
|
||||||
steps.push(() => locator.fill(params.text));
|
|
||||||
}
|
|
||||||
|
|
||||||
if (params.submit) {
|
|
||||||
code.push(`// Submit text`);
|
|
||||||
code.push(`await page.${await generateLocator(locator)}.press('Enter');`);
|
|
||||||
steps.push(() => locator.press('Enter'));
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
code,
|
|
||||||
action: () => steps.reduce((acc, step) => acc.then(step), Promise.resolve()),
|
|
||||||
captureSnapshot: true,
|
|
||||||
waitForNetwork: true,
|
|
||||||
};
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -188,7 +135,7 @@ const selectOptionSchema = elementSchema.extend({
|
|||||||
values: z.array(z.string()).describe('Array of values to select in the dropdown. This can be a single value or multiple values.'),
|
values: z.array(z.string()).describe('Array of values to select in the dropdown. This can be a single value or multiple values.'),
|
||||||
});
|
});
|
||||||
|
|
||||||
const selectOption = defineTool({
|
const selectOption = defineTabTool({
|
||||||
capability: 'core',
|
capability: 'core',
|
||||||
schema: {
|
schema: {
|
||||||
name: 'browser_select_option',
|
name: 'browser_select_option',
|
||||||
@@ -198,21 +145,15 @@ const selectOption = defineTool({
|
|||||||
type: 'destructive',
|
type: 'destructive',
|
||||||
},
|
},
|
||||||
|
|
||||||
handle: async (context, params) => {
|
handle: async (tab, params, response) => {
|
||||||
const snapshot = context.currentTabOrDie().snapshotOrDie();
|
response.setIncludeSnapshot();
|
||||||
const locator = snapshot.refLocator(params);
|
|
||||||
|
|
||||||
const code = [
|
const locator = await tab.refLocator(params);
|
||||||
`// Select options [${params.values.join(', ')}] in ${params.element}`,
|
response.addCode(`await page.${await generateLocator(locator)}.selectOption(${javascript.formatObject(params.values)});`);
|
||||||
`await page.${await generateLocator(locator)}.selectOption(${javascript.formatObject(params.values)});`
|
|
||||||
];
|
|
||||||
|
|
||||||
return {
|
await tab.waitForCompletion(async () => {
|
||||||
code,
|
await locator.selectOption(params.values);
|
||||||
action: () => locator.selectOption(params.values).then(() => {}),
|
});
|
||||||
captureSnapshot: true,
|
|
||||||
waitForNetwork: true,
|
|
||||||
};
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -221,6 +162,5 @@ export default [
|
|||||||
click,
|
click,
|
||||||
drag,
|
drag,
|
||||||
hover,
|
hover,
|
||||||
type,
|
|
||||||
selectOption,
|
selectOption,
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -15,10 +15,10 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
import { defineTool, type ToolFactory } from './tool.js';
|
import { defineTool } from './tool.js';
|
||||||
|
|
||||||
const listTabs = defineTool({
|
const listTabs = defineTool({
|
||||||
capability: 'tabs',
|
capability: 'core-tabs',
|
||||||
|
|
||||||
schema: {
|
schema: {
|
||||||
name: 'browser_tab_list',
|
name: 'browser_tab_list',
|
||||||
@@ -28,24 +28,14 @@ const listTabs = defineTool({
|
|||||||
type: 'readOnly',
|
type: 'readOnly',
|
||||||
},
|
},
|
||||||
|
|
||||||
handle: async context => {
|
handle: async (context, params, response) => {
|
||||||
await context.ensureTab();
|
await context.ensureTab();
|
||||||
return {
|
response.setIncludeTabs();
|
||||||
code: [`// <internal code to list tabs>`],
|
|
||||||
captureSnapshot: false,
|
|
||||||
waitForNetwork: false,
|
|
||||||
resultOverride: {
|
|
||||||
content: [{
|
|
||||||
type: 'text',
|
|
||||||
text: await context.listTabsMarkdown(),
|
|
||||||
}],
|
|
||||||
},
|
|
||||||
};
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const selectTab: ToolFactory = captureSnapshot => defineTool({
|
const selectTab = defineTool({
|
||||||
capability: 'tabs',
|
capability: 'core-tabs',
|
||||||
|
|
||||||
schema: {
|
schema: {
|
||||||
name: 'browser_tab_select',
|
name: 'browser_tab_select',
|
||||||
@@ -57,22 +47,14 @@ const selectTab: ToolFactory = captureSnapshot => defineTool({
|
|||||||
type: 'readOnly',
|
type: 'readOnly',
|
||||||
},
|
},
|
||||||
|
|
||||||
handle: async (context, params) => {
|
handle: async (context, params, response) => {
|
||||||
await context.selectTab(params.index);
|
await context.selectTab(params.index);
|
||||||
const code = [
|
response.setIncludeSnapshot();
|
||||||
`// <internal code to select tab ${params.index}>`,
|
|
||||||
];
|
|
||||||
|
|
||||||
return {
|
|
||||||
code,
|
|
||||||
captureSnapshot,
|
|
||||||
waitForNetwork: false
|
|
||||||
};
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const newTab: ToolFactory = captureSnapshot => defineTool({
|
const newTab = defineTool({
|
||||||
capability: 'tabs',
|
capability: 'core-tabs',
|
||||||
|
|
||||||
schema: {
|
schema: {
|
||||||
name: 'browser_tab_new',
|
name: 'browser_tab_new',
|
||||||
@@ -84,24 +66,16 @@ const newTab: ToolFactory = captureSnapshot => defineTool({
|
|||||||
type: 'readOnly',
|
type: 'readOnly',
|
||||||
},
|
},
|
||||||
|
|
||||||
handle: async (context, params) => {
|
handle: async (context, params, response) => {
|
||||||
await context.newTab();
|
const tab = await context.newTab();
|
||||||
if (params.url)
|
if (params.url)
|
||||||
await context.currentTabOrDie().navigate(params.url);
|
await tab.navigate(params.url);
|
||||||
|
response.setIncludeSnapshot();
|
||||||
const code = [
|
|
||||||
`// <internal code to open a new tab>`,
|
|
||||||
];
|
|
||||||
return {
|
|
||||||
code,
|
|
||||||
captureSnapshot,
|
|
||||||
waitForNetwork: false
|
|
||||||
};
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const closeTab: ToolFactory = captureSnapshot => defineTool({
|
const closeTab = defineTool({
|
||||||
capability: 'tabs',
|
capability: 'core-tabs',
|
||||||
|
|
||||||
schema: {
|
schema: {
|
||||||
name: 'browser_tab_close',
|
name: 'browser_tab_close',
|
||||||
@@ -113,22 +87,15 @@ const closeTab: ToolFactory = captureSnapshot => defineTool({
|
|||||||
type: 'destructive',
|
type: 'destructive',
|
||||||
},
|
},
|
||||||
|
|
||||||
handle: async (context, params) => {
|
handle: async (context, params, response) => {
|
||||||
await context.closeTab(params.index);
|
await context.closeTab(params.index);
|
||||||
const code = [
|
response.setIncludeSnapshot();
|
||||||
`// <internal code to close tab ${params.index}>`,
|
|
||||||
];
|
|
||||||
return {
|
|
||||||
code,
|
|
||||||
captureSnapshot,
|
|
||||||
waitForNetwork: false
|
|
||||||
};
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
export default (captureSnapshot: boolean) => [
|
export default [
|
||||||
listTabs,
|
listTabs,
|
||||||
newTab(captureSnapshot),
|
newTab,
|
||||||
selectTab(captureSnapshot),
|
selectTab,
|
||||||
closeTab(captureSnapshot),
|
closeTab,
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -1,67 +0,0 @@
|
|||||||
/**
|
|
||||||
* Copyright (c) Microsoft Corporation.
|
|
||||||
*
|
|
||||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
|
||||||
* you may not use this file except in compliance with the License.
|
|
||||||
* You may obtain a copy of the License at
|
|
||||||
*
|
|
||||||
* http://www.apache.org/licenses/LICENSE-2.0
|
|
||||||
*
|
|
||||||
* Unless required by applicable law or agreed to in writing, software
|
|
||||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
|
||||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
||||||
* See the License for the specific language governing permissions and
|
|
||||||
* limitations under the License.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { z } from 'zod';
|
|
||||||
import { defineTool } from './tool.js';
|
|
||||||
|
|
||||||
const generateTestSchema = z.object({
|
|
||||||
name: z.string().describe('The name of the test'),
|
|
||||||
description: z.string().describe('The description of the test'),
|
|
||||||
steps: z.array(z.string()).describe('The steps of the test'),
|
|
||||||
});
|
|
||||||
|
|
||||||
const generateTest = defineTool({
|
|
||||||
capability: 'testing',
|
|
||||||
|
|
||||||
schema: {
|
|
||||||
name: 'browser_generate_playwright_test',
|
|
||||||
title: 'Generate a Playwright test',
|
|
||||||
description: 'Generate a Playwright test for given scenario',
|
|
||||||
inputSchema: generateTestSchema,
|
|
||||||
type: 'readOnly',
|
|
||||||
},
|
|
||||||
|
|
||||||
handle: async (context, params) => {
|
|
||||||
return {
|
|
||||||
resultOverride: {
|
|
||||||
content: [{
|
|
||||||
type: 'text',
|
|
||||||
text: instructions(params),
|
|
||||||
}],
|
|
||||||
},
|
|
||||||
code: [],
|
|
||||||
captureSnapshot: false,
|
|
||||||
waitForNetwork: false,
|
|
||||||
};
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const instructions = (params: { name: string, description: string, steps: string[] }) => [
|
|
||||||
`## Instructions`,
|
|
||||||
`- You are a playwright test generator.`,
|
|
||||||
`- You are given a scenario and you need to generate a playwright test for it.`,
|
|
||||||
'- DO NOT generate test code based on the scenario alone. DO run steps one by one using the tools provided instead.',
|
|
||||||
'- Only after all steps are completed, emit a Playwright TypeScript test that uses @playwright/test based on message history',
|
|
||||||
'- Save generated test file in the tests directory',
|
|
||||||
`Test name: ${params.name}`,
|
|
||||||
`Description: ${params.description}`,
|
|
||||||
`Steps:`,
|
|
||||||
...params.steps.map((step, index) => `- ${index + 1}. ${step}`),
|
|
||||||
].join('\n');
|
|
||||||
|
|
||||||
export default [
|
|
||||||
generateTest,
|
|
||||||
];
|
|
||||||
@@ -14,21 +14,13 @@
|
|||||||
* limitations under the License.
|
* limitations under the License.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import type { ImageContent, TextContent } from '@modelcontextprotocol/sdk/types.js';
|
|
||||||
import type { z } from 'zod';
|
import type { z } from 'zod';
|
||||||
import type { Context } from '../context.js';
|
import type { Context } from '../context.js';
|
||||||
import type * as playwright from 'playwright';
|
import type * as playwright from 'playwright';
|
||||||
import type { ToolCapability } from '../../config.js';
|
import type { ToolCapability } from '../../config.js';
|
||||||
|
import type { Tab } from '../tab.js';
|
||||||
export type ToolSchema<Input extends InputType> = {
|
import type { Response } from '../response.js';
|
||||||
name: string;
|
import type { ToolSchema } from '../mcp/server.js';
|
||||||
title: string;
|
|
||||||
description: string;
|
|
||||||
inputSchema: Input;
|
|
||||||
type: 'readOnly' | 'destructive';
|
|
||||||
};
|
|
||||||
|
|
||||||
type InputType = z.Schema;
|
|
||||||
|
|
||||||
export type FileUploadModalState = {
|
export type FileUploadModalState = {
|
||||||
type: 'fileChooser';
|
type: 'fileChooser';
|
||||||
@@ -44,25 +36,35 @@ export type DialogModalState = {
|
|||||||
|
|
||||||
export type ModalState = FileUploadModalState | DialogModalState;
|
export type ModalState = FileUploadModalState | DialogModalState;
|
||||||
|
|
||||||
export type ToolActionResult = { content?: (ImageContent | TextContent)[] } | undefined | void;
|
export type Tool<Input extends z.Schema = z.Schema> = {
|
||||||
|
capability: ToolCapability;
|
||||||
export type ToolResult = {
|
schema: ToolSchema<Input>;
|
||||||
code: string[];
|
handle: (context: Context, params: z.output<Input>, response: Response) => Promise<void>;
|
||||||
action?: () => Promise<ToolActionResult>;
|
|
||||||
captureSnapshot: boolean;
|
|
||||||
waitForNetwork: boolean;
|
|
||||||
resultOverride?: ToolActionResult;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export type Tool<Input extends InputType = InputType> = {
|
export function defineTool<Input extends z.Schema>(tool: Tool<Input>): Tool<Input> {
|
||||||
|
return tool;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type TabTool<Input extends z.Schema = z.Schema> = {
|
||||||
capability: ToolCapability;
|
capability: ToolCapability;
|
||||||
schema: ToolSchema<Input>;
|
schema: ToolSchema<Input>;
|
||||||
clearsModalState?: ModalState['type'];
|
clearsModalState?: ModalState['type'];
|
||||||
handle: (context: Context, params: z.output<Input>) => Promise<ToolResult>;
|
handle: (tab: Tab, params: z.output<Input>, response: Response) => Promise<void>;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type ToolFactory = (snapshot: boolean) => Tool<any>;
|
export function defineTabTool<Input extends z.Schema>(tool: TabTool<Input>): Tool<Input> {
|
||||||
|
return {
|
||||||
export function defineTool<Input extends InputType>(tool: Tool<Input>): Tool<Input> {
|
...tool,
|
||||||
return tool;
|
handle: async (context, params, response) => {
|
||||||
|
const tab = context.currentTabOrDie();
|
||||||
|
const modalStates = tab.modalStates().map(state => state.type);
|
||||||
|
if (tool.clearsModalState && !modalStates.includes(tool.clearsModalState))
|
||||||
|
response.addError(`Error: The tool "${tool.schema.name}" can only be used when there is related modal state present.\n` + tab.modalStatesMarkdown().join('\n'));
|
||||||
|
else if (!tool.clearsModalState && modalStates.length)
|
||||||
|
response.addError(`Error: Tool "${tool.schema.name}" does not handle the modal state.\n` + tab.modalStatesMarkdown().join('\n'));
|
||||||
|
else
|
||||||
|
return tool.handle(tab, params, response);
|
||||||
|
},
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,11 +14,13 @@
|
|||||||
* limitations under the License.
|
* limitations under the License.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
// @ts-ignore
|
||||||
|
import { asLocator } from 'playwright-core/lib/utils';
|
||||||
|
|
||||||
import type * as playwright from 'playwright';
|
import type * as playwright from 'playwright';
|
||||||
import type { Context } from '../context.js';
|
|
||||||
import type { Tab } from '../tab.js';
|
import type { Tab } from '../tab.js';
|
||||||
|
|
||||||
export async function waitForCompletion<R>(context: Context, tab: Tab, callback: () => Promise<R>): Promise<R> {
|
export async function waitForCompletion<R>(tab: Tab, callback: () => Promise<R>): Promise<R> {
|
||||||
const requests = new Set<playwright.Request>();
|
const requests = new Set<playwright.Request>();
|
||||||
let frameNavigated = false;
|
let frameNavigated = false;
|
||||||
let waitCallback: () => void = () => {};
|
let waitCallback: () => void = () => {};
|
||||||
@@ -62,23 +64,20 @@ export async function waitForCompletion<R>(context: Context, tab: Tab, callback:
|
|||||||
if (!requests.size && !frameNavigated)
|
if (!requests.size && !frameNavigated)
|
||||||
waitCallback();
|
waitCallback();
|
||||||
await waitBarrier;
|
await waitBarrier;
|
||||||
await context.waitForTimeout(1000);
|
await tab.waitForTimeout(1000);
|
||||||
return result;
|
return result;
|
||||||
} finally {
|
} finally {
|
||||||
dispose();
|
dispose();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function sanitizeForFilePath(s: string) {
|
|
||||||
const sanitize = (s: string) => s.replace(/[\x00-\x2C\x2E-\x2F\x3A-\x40\x5B-\x60\x7B-\x7F]+/g, '-');
|
|
||||||
const separator = s.lastIndexOf('.');
|
|
||||||
if (separator === -1)
|
|
||||||
return sanitize(s);
|
|
||||||
return sanitize(s.substring(0, separator)) + '.' + sanitize(s.substring(separator + 1));
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function generateLocator(locator: playwright.Locator): Promise<string> {
|
export async function generateLocator(locator: playwright.Locator): Promise<string> {
|
||||||
return (locator as any)._generateLocatorString();
|
try {
|
||||||
|
const { resolvedSelector } = await (locator as any)._resolveSelector();
|
||||||
|
return asLocator('javascript', resolvedSelector);
|
||||||
|
} catch (e) {
|
||||||
|
throw new Error('Ref not found, likely because element was removed. Use browser_snapshot to see what elements are currently on the page.');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function callOnPageNoTrace<T>(page: playwright.Page, callback: (page: playwright.Page) => Promise<T>): Promise<T> {
|
export async function callOnPageNoTrace<T>(page: playwright.Page, callback: (page: playwright.Page) => Promise<T>): Promise<T> {
|
||||||
|
|||||||
@@ -1,213 +0,0 @@
|
|||||||
/**
|
|
||||||
* Copyright (c) Microsoft Corporation.
|
|
||||||
*
|
|
||||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
|
||||||
* you may not use this file except in compliance with the License.
|
|
||||||
* You may obtain a copy of the License at
|
|
||||||
*
|
|
||||||
* http://www.apache.org/licenses/LICENSE-2.0
|
|
||||||
*
|
|
||||||
* Unless required by applicable law or agreed to in writing, software
|
|
||||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
|
||||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
||||||
* See the License for the specific language governing permissions and
|
|
||||||
* limitations under the License.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { z } from 'zod';
|
|
||||||
import { defineTool } from './tool.js';
|
|
||||||
|
|
||||||
import * as javascript from '../javascript.js';
|
|
||||||
|
|
||||||
const elementSchema = z.object({
|
|
||||||
element: z.string().describe('Human-readable element description used to obtain permission to interact with the element'),
|
|
||||||
});
|
|
||||||
|
|
||||||
const screenshot = defineTool({
|
|
||||||
capability: 'core',
|
|
||||||
schema: {
|
|
||||||
name: 'browser_screen_capture',
|
|
||||||
title: 'Take a screenshot',
|
|
||||||
description: 'Take a screenshot of the current page',
|
|
||||||
inputSchema: z.object({}),
|
|
||||||
type: 'readOnly',
|
|
||||||
},
|
|
||||||
|
|
||||||
handle: async context => {
|
|
||||||
const tab = await context.ensureTab();
|
|
||||||
const options = { type: 'jpeg' as 'jpeg', quality: 50, scale: 'css' as 'css' };
|
|
||||||
|
|
||||||
const code = [
|
|
||||||
`// Take a screenshot of the current page`,
|
|
||||||
`await page.screenshot(${javascript.formatObject(options)});`,
|
|
||||||
];
|
|
||||||
|
|
||||||
const action = () => tab.page.screenshot(options).then(buffer => {
|
|
||||||
return {
|
|
||||||
content: [{ type: 'image' as 'image', data: buffer.toString('base64'), mimeType: 'image/jpeg' }],
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
return {
|
|
||||||
code,
|
|
||||||
action,
|
|
||||||
captureSnapshot: false,
|
|
||||||
waitForNetwork: false
|
|
||||||
};
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const moveMouse = defineTool({
|
|
||||||
capability: 'core',
|
|
||||||
schema: {
|
|
||||||
name: 'browser_screen_move_mouse',
|
|
||||||
title: 'Move mouse',
|
|
||||||
description: 'Move mouse to a given position',
|
|
||||||
inputSchema: elementSchema.extend({
|
|
||||||
x: z.number().describe('X coordinate'),
|
|
||||||
y: z.number().describe('Y coordinate'),
|
|
||||||
}),
|
|
||||||
type: 'readOnly',
|
|
||||||
},
|
|
||||||
|
|
||||||
handle: async (context, params) => {
|
|
||||||
const tab = context.currentTabOrDie();
|
|
||||||
const code = [
|
|
||||||
`// Move mouse to (${params.x}, ${params.y})`,
|
|
||||||
`await page.mouse.move(${params.x}, ${params.y});`,
|
|
||||||
];
|
|
||||||
const action = () => tab.page.mouse.move(params.x, params.y);
|
|
||||||
return {
|
|
||||||
code,
|
|
||||||
action,
|
|
||||||
captureSnapshot: false,
|
|
||||||
waitForNetwork: false
|
|
||||||
};
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const click = defineTool({
|
|
||||||
capability: 'core',
|
|
||||||
schema: {
|
|
||||||
name: 'browser_screen_click',
|
|
||||||
title: 'Click',
|
|
||||||
description: 'Click left mouse button',
|
|
||||||
inputSchema: elementSchema.extend({
|
|
||||||
x: z.number().describe('X coordinate'),
|
|
||||||
y: z.number().describe('Y coordinate'),
|
|
||||||
}),
|
|
||||||
type: 'destructive',
|
|
||||||
},
|
|
||||||
|
|
||||||
handle: async (context, params) => {
|
|
||||||
const tab = context.currentTabOrDie();
|
|
||||||
const code = [
|
|
||||||
`// Click mouse at coordinates (${params.x}, ${params.y})`,
|
|
||||||
`await page.mouse.move(${params.x}, ${params.y});`,
|
|
||||||
`await page.mouse.down();`,
|
|
||||||
`await page.mouse.up();`,
|
|
||||||
];
|
|
||||||
const action = async () => {
|
|
||||||
await tab.page.mouse.move(params.x, params.y);
|
|
||||||
await tab.page.mouse.down();
|
|
||||||
await tab.page.mouse.up();
|
|
||||||
};
|
|
||||||
return {
|
|
||||||
code,
|
|
||||||
action,
|
|
||||||
captureSnapshot: false,
|
|
||||||
waitForNetwork: true,
|
|
||||||
};
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const drag = defineTool({
|
|
||||||
capability: 'core',
|
|
||||||
schema: {
|
|
||||||
name: 'browser_screen_drag',
|
|
||||||
title: 'Drag mouse',
|
|
||||||
description: 'Drag left mouse button',
|
|
||||||
inputSchema: elementSchema.extend({
|
|
||||||
startX: z.number().describe('Start X coordinate'),
|
|
||||||
startY: z.number().describe('Start Y coordinate'),
|
|
||||||
endX: z.number().describe('End X coordinate'),
|
|
||||||
endY: z.number().describe('End Y coordinate'),
|
|
||||||
}),
|
|
||||||
type: 'destructive',
|
|
||||||
},
|
|
||||||
|
|
||||||
handle: async (context, params) => {
|
|
||||||
const tab = context.currentTabOrDie();
|
|
||||||
|
|
||||||
const code = [
|
|
||||||
`// Drag mouse from (${params.startX}, ${params.startY}) to (${params.endX}, ${params.endY})`,
|
|
||||||
`await page.mouse.move(${params.startX}, ${params.startY});`,
|
|
||||||
`await page.mouse.down();`,
|
|
||||||
`await page.mouse.move(${params.endX}, ${params.endY});`,
|
|
||||||
`await page.mouse.up();`,
|
|
||||||
];
|
|
||||||
|
|
||||||
const action = async () => {
|
|
||||||
await tab.page.mouse.move(params.startX, params.startY);
|
|
||||||
await tab.page.mouse.down();
|
|
||||||
await tab.page.mouse.move(params.endX, params.endY);
|
|
||||||
await tab.page.mouse.up();
|
|
||||||
};
|
|
||||||
|
|
||||||
return {
|
|
||||||
code,
|
|
||||||
action,
|
|
||||||
captureSnapshot: false,
|
|
||||||
waitForNetwork: true,
|
|
||||||
};
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const type = defineTool({
|
|
||||||
capability: 'core',
|
|
||||||
schema: {
|
|
||||||
name: 'browser_screen_type',
|
|
||||||
title: 'Type text',
|
|
||||||
description: 'Type text',
|
|
||||||
inputSchema: z.object({
|
|
||||||
text: z.string().describe('Text to type into the element'),
|
|
||||||
submit: z.boolean().optional().describe('Whether to submit entered text (press Enter after)'),
|
|
||||||
}),
|
|
||||||
type: 'destructive',
|
|
||||||
},
|
|
||||||
|
|
||||||
handle: async (context, params) => {
|
|
||||||
const tab = context.currentTabOrDie();
|
|
||||||
|
|
||||||
const code = [
|
|
||||||
`// Type ${params.text}`,
|
|
||||||
`await page.keyboard.type('${params.text}');`,
|
|
||||||
];
|
|
||||||
|
|
||||||
const action = async () => {
|
|
||||||
await tab.page.keyboard.type(params.text);
|
|
||||||
if (params.submit)
|
|
||||||
await tab.page.keyboard.press('Enter');
|
|
||||||
};
|
|
||||||
|
|
||||||
if (params.submit) {
|
|
||||||
code.push(`// Submit text`);
|
|
||||||
code.push(`await page.keyboard.press('Enter');`);
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
code,
|
|
||||||
action,
|
|
||||||
captureSnapshot: false,
|
|
||||||
waitForNetwork: true,
|
|
||||||
};
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
export default [
|
|
||||||
screenshot,
|
|
||||||
moveMouse,
|
|
||||||
click,
|
|
||||||
drag,
|
|
||||||
type,
|
|
||||||
];
|
|
||||||
@@ -15,10 +15,10 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
import { defineTool, type ToolFactory } from './tool.js';
|
import { defineTool } from './tool.js';
|
||||||
|
|
||||||
const wait: ToolFactory = captureSnapshot => defineTool({
|
const wait = defineTool({
|
||||||
capability: 'wait',
|
capability: 'core',
|
||||||
|
|
||||||
schema: {
|
schema: {
|
||||||
name: 'browser_wait_for',
|
name: 'browser_wait_for',
|
||||||
@@ -32,15 +32,17 @@ const wait: ToolFactory = captureSnapshot => defineTool({
|
|||||||
type: 'readOnly',
|
type: 'readOnly',
|
||||||
},
|
},
|
||||||
|
|
||||||
handle: async (context, params) => {
|
handle: async (context, params, response) => {
|
||||||
if (!params.text && !params.textGone && !params.time)
|
if (!params.text && !params.textGone && !params.time)
|
||||||
throw new Error('Either time, text or textGone must be provided');
|
throw new Error('Either time, text or textGone must be provided');
|
||||||
|
|
||||||
const code: string[] = [];
|
const code: string[] = [];
|
||||||
|
|
||||||
if (params.time) {
|
if (params.time) {
|
||||||
code.push(`await new Promise(f => setTimeout(f, ${params.time!} * 1000));`);
|
const timeCode = `await new Promise(f => setTimeout(f, ${params.time!} * 1000));`;
|
||||||
await new Promise(f => setTimeout(f, Math.min(10000, params.time! * 1000)));
|
code.push(timeCode);
|
||||||
|
response.addCode(timeCode);
|
||||||
|
await new Promise(f => setTimeout(f, Math.min(30000, params.time! * 1000)));
|
||||||
}
|
}
|
||||||
|
|
||||||
const tab = context.currentTabOrDie();
|
const tab = context.currentTabOrDie();
|
||||||
@@ -48,23 +50,24 @@ const wait: ToolFactory = captureSnapshot => defineTool({
|
|||||||
const goneLocator = params.textGone ? tab.page.getByText(params.textGone).first() : undefined;
|
const goneLocator = params.textGone ? tab.page.getByText(params.textGone).first() : undefined;
|
||||||
|
|
||||||
if (goneLocator) {
|
if (goneLocator) {
|
||||||
code.push(`await page.getByText(${JSON.stringify(params.textGone)}).first().waitFor({ state: 'hidden' });`);
|
const goneCode = `await page.getByText(${JSON.stringify(params.textGone)}).first().waitFor({ state: 'hidden' });`;
|
||||||
|
code.push(goneCode);
|
||||||
|
response.addCode(goneCode);
|
||||||
await goneLocator.waitFor({ state: 'hidden' });
|
await goneLocator.waitFor({ state: 'hidden' });
|
||||||
}
|
}
|
||||||
|
|
||||||
if (locator) {
|
if (locator) {
|
||||||
code.push(`await page.getByText(${JSON.stringify(params.text)}).first().waitFor({ state: 'visible' });`);
|
const locatorCode = `await page.getByText(${JSON.stringify(params.text)}).first().waitFor({ state: 'visible' });`;
|
||||||
|
code.push(locatorCode);
|
||||||
|
response.addCode(locatorCode);
|
||||||
await locator.waitFor({ state: 'visible' });
|
await locator.waitFor({ state: 'visible' });
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
response.addResult(`Waited for ${params.text || params.textGone || params.time}`);
|
||||||
code,
|
response.setIncludeSnapshot();
|
||||||
captureSnapshot,
|
|
||||||
waitForNetwork: false,
|
|
||||||
};
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
export default (captureSnapshot: boolean) => [
|
export default [
|
||||||
wait(captureSnapshot),
|
wait,
|
||||||
];
|
];
|
||||||
|
|||||||
29
src/utils.ts
Normal file
29
src/utils.ts
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
/**
|
||||||
|
* 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 crypto from 'crypto';
|
||||||
|
|
||||||
|
export function createHash(data: string): string {
|
||||||
|
return crypto.createHash('sha256').update(data).digest('hex').slice(0, 7);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function sanitizeForFilePath(s: string) {
|
||||||
|
const sanitize = (s: string) => s.replace(/[\x00-\x2C\x2E-\x2F\x3A-\x40\x5B-\x60\x7B-\x7F]+/g, '-');
|
||||||
|
const separator = s.lastIndexOf('.');
|
||||||
|
if (separator === -1)
|
||||||
|
return sanitize(s);
|
||||||
|
return sanitize(s.substring(0, separator)) + '.' + sanitize(s.substring(separator + 1));
|
||||||
|
}
|
||||||
@@ -1,77 +0,0 @@
|
|||||||
/**
|
|
||||||
* Copyright (c) Microsoft Corporation.
|
|
||||||
*
|
|
||||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
|
||||||
* you may not use this file except in compliance with the License.
|
|
||||||
* You may obtain a copy of the License at
|
|
||||||
*
|
|
||||||
* http://www.apache.org/licenses/LICENSE-2.0
|
|
||||||
*
|
|
||||||
* Unless required by applicable law or agreed to in writing, software
|
|
||||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
|
||||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
||||||
* See the License for the specific language governing permissions and
|
|
||||||
* limitations under the License.
|
|
||||||
*/
|
|
||||||
import path from 'path';
|
|
||||||
import url from 'node:url';
|
|
||||||
|
|
||||||
import { spawn } from 'child_process';
|
|
||||||
import { test as baseTest, expect } from './fixtures.js';
|
|
||||||
|
|
||||||
import type { ChildProcess } from 'child_process';
|
|
||||||
|
|
||||||
const __filename = url.fileURLToPath(import.meta.url);
|
|
||||||
|
|
||||||
const test = baseTest.extend<{ agentEndpoint: (options?: { args?: string[] }) => Promise<{ url: URL, stdout: () => string }> }>({
|
|
||||||
agentEndpoint: async ({}, use) => {
|
|
||||||
let cp: ChildProcess | undefined;
|
|
||||||
await use(async (options?: { args?: string[] }) => {
|
|
||||||
if (cp)
|
|
||||||
throw new Error('Process already running');
|
|
||||||
|
|
||||||
cp = spawn('node', [
|
|
||||||
path.join(path.dirname(__filename), '../lib/browserServer.js'),
|
|
||||||
...(options?.args || []),
|
|
||||||
], {
|
|
||||||
stdio: 'pipe',
|
|
||||||
env: {
|
|
||||||
...process.env,
|
|
||||||
DEBUG: 'pw:mcp:test',
|
|
||||||
DEBUG_COLORS: '0',
|
|
||||||
DEBUG_HIDE_DATE: '1',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
let stdout = '';
|
|
||||||
const url = await new Promise<string>(resolve => cp!.stdout?.on('data', data => {
|
|
||||||
stdout += data.toString();
|
|
||||||
const match = stdout.match(/Listening on (http:\/\/.*)/);
|
|
||||||
if (match)
|
|
||||||
resolve(match[1]);
|
|
||||||
}));
|
|
||||||
|
|
||||||
return { url: new URL(url), stdout: () => stdout };
|
|
||||||
});
|
|
||||||
cp?.kill('SIGTERM');
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
test.skip(({ mcpBrowser }) => mcpBrowser !== 'chrome', 'Agent is CDP-only for now');
|
|
||||||
|
|
||||||
test('browser lifecycle', async ({ agentEndpoint, startClient, server }) => {
|
|
||||||
const { url: agentUrl } = await agentEndpoint();
|
|
||||||
const { client: client1 } = await startClient({ args: ['--browser-agent', agentUrl.toString()] });
|
|
||||||
expect(await client1.callTool({
|
|
||||||
name: 'browser_navigate',
|
|
||||||
arguments: { url: server.HELLO_WORLD },
|
|
||||||
})).toContainTextContent('Hello, world!');
|
|
||||||
|
|
||||||
const { client: client2 } = await startClient({ args: ['--browser-agent', agentUrl.toString()] });
|
|
||||||
expect(await client2.callTool({
|
|
||||||
name: 'browser_navigate',
|
|
||||||
arguments: { url: server.HELLO_WORLD },
|
|
||||||
})).toContainTextContent('Hello, world!');
|
|
||||||
|
|
||||||
await client1.close();
|
|
||||||
await client2.close();
|
|
||||||
});
|
|
||||||
@@ -22,8 +22,8 @@ test('test snapshot tool list', async ({ client }) => {
|
|||||||
'browser_click',
|
'browser_click',
|
||||||
'browser_console_messages',
|
'browser_console_messages',
|
||||||
'browser_drag',
|
'browser_drag',
|
||||||
|
'browser_evaluate',
|
||||||
'browser_file_upload',
|
'browser_file_upload',
|
||||||
'browser_generate_playwright_test',
|
|
||||||
'browser_handle_dialog',
|
'browser_handle_dialog',
|
||||||
'browser_hover',
|
'browser_hover',
|
||||||
'browser_select_option',
|
'browser_select_option',
|
||||||
@@ -34,7 +34,6 @@ test('test snapshot tool list', async ({ client }) => {
|
|||||||
'browser_navigate_forward',
|
'browser_navigate_forward',
|
||||||
'browser_navigate',
|
'browser_navigate',
|
||||||
'browser_network_requests',
|
'browser_network_requests',
|
||||||
'browser_pdf_save',
|
|
||||||
'browser_press_key',
|
'browser_press_key',
|
||||||
'browser_resize',
|
'browser_resize',
|
||||||
'browser_snapshot',
|
'browser_snapshot',
|
||||||
@@ -47,46 +46,33 @@ test('test snapshot tool list', async ({ client }) => {
|
|||||||
]));
|
]));
|
||||||
});
|
});
|
||||||
|
|
||||||
test('test vision tool list', async ({ visionClient }) => {
|
test('test capabilities (pdf)', async ({ startClient }) => {
|
||||||
const { tools: visionTools } = await visionClient.listTools();
|
|
||||||
expect(new Set(visionTools.map(t => t.name))).toEqual(new Set([
|
|
||||||
'browser_close',
|
|
||||||
'browser_console_messages',
|
|
||||||
'browser_file_upload',
|
|
||||||
'browser_generate_playwright_test',
|
|
||||||
'browser_handle_dialog',
|
|
||||||
'browser_install',
|
|
||||||
'browser_navigate_back',
|
|
||||||
'browser_navigate_forward',
|
|
||||||
'browser_navigate',
|
|
||||||
'browser_network_requests',
|
|
||||||
'browser_pdf_save',
|
|
||||||
'browser_press_key',
|
|
||||||
'browser_resize',
|
|
||||||
'browser_screen_capture',
|
|
||||||
'browser_screen_click',
|
|
||||||
'browser_screen_drag',
|
|
||||||
'browser_screen_move_mouse',
|
|
||||||
'browser_screen_type',
|
|
||||||
'browser_tab_close',
|
|
||||||
'browser_tab_list',
|
|
||||||
'browser_tab_new',
|
|
||||||
'browser_tab_select',
|
|
||||||
'browser_wait_for',
|
|
||||||
]));
|
|
||||||
});
|
|
||||||
|
|
||||||
test('test capabilities', async ({ startClient }) => {
|
|
||||||
const { client } = await startClient({
|
const { client } = await startClient({
|
||||||
args: ['--caps="core"'],
|
args: ['--caps=pdf'],
|
||||||
});
|
});
|
||||||
const { tools } = await client.listTools();
|
const { tools } = await client.listTools();
|
||||||
const toolNames = tools.map(t => t.name);
|
const toolNames = tools.map(t => t.name);
|
||||||
expect(toolNames).not.toContain('browser_file_upload');
|
expect(toolNames).toContain('browser_pdf_save');
|
||||||
expect(toolNames).not.toContain('browser_pdf_save');
|
});
|
||||||
expect(toolNames).not.toContain('browser_screen_capture');
|
|
||||||
expect(toolNames).not.toContain('browser_screen_click');
|
test('test capabilities (vision)', async ({ startClient }) => {
|
||||||
expect(toolNames).not.toContain('browser_screen_drag');
|
const { client } = await startClient({
|
||||||
expect(toolNames).not.toContain('browser_screen_move_mouse');
|
args: ['--caps=vision'],
|
||||||
expect(toolNames).not.toContain('browser_screen_type');
|
});
|
||||||
|
const { tools } = await client.listTools();
|
||||||
|
const toolNames = tools.map(t => t.name);
|
||||||
|
expect(toolNames).toContain('browser_mouse_move_xy');
|
||||||
|
expect(toolNames).toContain('browser_mouse_click_xy');
|
||||||
|
expect(toolNames).toContain('browser_mouse_drag_xy');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('support for legacy --vision option', async ({ startClient }) => {
|
||||||
|
const { client } = await startClient({
|
||||||
|
args: ['--vision'],
|
||||||
|
});
|
||||||
|
const { tools } = await client.listTools();
|
||||||
|
const toolNames = tools.map(t => t.name);
|
||||||
|
expect(toolNames).toContain('browser_mouse_move_xy');
|
||||||
|
expect(toolNames).toContain('browser_mouse_click_xy');
|
||||||
|
expect(toolNames).toContain('browser_mouse_drag_xy');
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -14,6 +14,9 @@
|
|||||||
* limitations under the License.
|
* limitations under the License.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import url from 'node:url';
|
||||||
|
import path from 'node:path';
|
||||||
|
import { spawnSync } from 'node:child_process';
|
||||||
import { test, expect } from './fixtures.js';
|
import { test, expect } from './fixtures.js';
|
||||||
|
|
||||||
test('cdp server', async ({ cdpServer, startClient, server }) => {
|
test('cdp server', async ({ cdpServer, startClient, server }) => {
|
||||||
@@ -22,7 +25,9 @@ test('cdp server', async ({ cdpServer, startClient, server }) => {
|
|||||||
expect(await client.callTool({
|
expect(await client.callTool({
|
||||||
name: 'browser_navigate',
|
name: 'browser_navigate',
|
||||||
arguments: { url: server.HELLO_WORLD },
|
arguments: { url: server.HELLO_WORLD },
|
||||||
})).toContainTextContent(`- generic [ref=e1]: Hello, world!`);
|
})).toHaveResponse({
|
||||||
|
pageState: expect.stringContaining(`- generic [active] [ref=e1]: Hello, world!`),
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
test('cdp server reuse tab', async ({ cdpServer, startClient, server }) => {
|
test('cdp server reuse tab', async ({ cdpServer, startClient, server }) => {
|
||||||
@@ -38,23 +43,21 @@ test('cdp server reuse tab', async ({ cdpServer, startClient, server }) => {
|
|||||||
element: 'Hello, world!',
|
element: 'Hello, world!',
|
||||||
ref: 'f0',
|
ref: 'f0',
|
||||||
},
|
},
|
||||||
})).toHaveTextContent(`Error: No current snapshot available. Capture a snapshot or navigate to a new location first.`);
|
})).toHaveResponse({
|
||||||
|
result: `Error: No open pages available. Use the "browser_navigate" tool to navigate to a page first.`,
|
||||||
|
isError: true,
|
||||||
|
});
|
||||||
|
|
||||||
expect(await client.callTool({
|
expect(await client.callTool({
|
||||||
name: 'browser_snapshot',
|
name: 'browser_snapshot',
|
||||||
})).toHaveTextContent(`
|
})).toHaveResponse({
|
||||||
- Ran Playwright code:
|
pageState: expect.stringContaining(`- Page URL: ${server.HELLO_WORLD}
|
||||||
\`\`\`js
|
|
||||||
// <internal code to capture accessibility snapshot>
|
|
||||||
\`\`\`
|
|
||||||
|
|
||||||
- Page URL: ${server.HELLO_WORLD}
|
|
||||||
- Page Title: Title
|
- Page Title: Title
|
||||||
- Page Snapshot
|
- Page Snapshot:
|
||||||
\`\`\`yaml
|
\`\`\`yaml
|
||||||
- generic [ref=e1]: Hello, world!
|
- generic [active] [ref=e1]: Hello, world!
|
||||||
\`\`\`
|
\`\`\``),
|
||||||
`);
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
test('should throw connection error and allow re-connecting', async ({ cdpServer, startClient, server }) => {
|
test('should throw connection error and allow re-connecting', async ({ cdpServer, startClient, server }) => {
|
||||||
@@ -68,10 +71,27 @@ test('should throw connection error and allow re-connecting', async ({ cdpServer
|
|||||||
expect(await client.callTool({
|
expect(await client.callTool({
|
||||||
name: 'browser_navigate',
|
name: 'browser_navigate',
|
||||||
arguments: { url: server.PREFIX },
|
arguments: { url: server.PREFIX },
|
||||||
})).toContainTextContent(`Error: browserType.connectOverCDP: connect ECONNREFUSED`);
|
})).toHaveResponse({
|
||||||
|
result: expect.stringContaining(`Error: browserType.connectOverCDP: connect ECONNREFUSED`),
|
||||||
|
isError: true,
|
||||||
|
});
|
||||||
await cdpServer.start();
|
await cdpServer.start();
|
||||||
expect(await client.callTool({
|
expect(await client.callTool({
|
||||||
name: 'browser_navigate',
|
name: 'browser_navigate',
|
||||||
arguments: { url: server.PREFIX },
|
arguments: { url: server.PREFIX },
|
||||||
})).toContainTextContent(`- generic [ref=e1]: Hello, world!`);
|
})).toHaveResponse({
|
||||||
|
pageState: expect.stringContaining(`- generic [active] [ref=e1]: Hello, world!`),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// NOTE: Can be removed when we drop Node.js 18 support and changed to import.meta.filename.
|
||||||
|
const __filename = url.fileURLToPath(import.meta.url);
|
||||||
|
|
||||||
|
test('does not support --device', async () => {
|
||||||
|
const result = spawnSync('node', [
|
||||||
|
path.join(__filename, '../../cli.js'), '--device=Pixel 5', '--cdp-endpoint=http://localhost:1234',
|
||||||
|
]);
|
||||||
|
expect(result.error).toBeUndefined();
|
||||||
|
expect(result.status).toBe(1);
|
||||||
|
expect(result.stderr.toString()).toContain('Device emulation is not supported with cdpEndpoint.');
|
||||||
});
|
});
|
||||||
|
|||||||
99
tests/click.spec.ts
Normal file
99
tests/click.spec.ts
Normal file
@@ -0,0 +1,99 @@
|
|||||||
|
/**
|
||||||
|
* 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_click', async ({ client, server, mcpBrowser }) => {
|
||||||
|
server.setContent('/', `
|
||||||
|
<title>Title</title>
|
||||||
|
<button>Submit</button>
|
||||||
|
`, 'text/html');
|
||||||
|
|
||||||
|
await client.callTool({
|
||||||
|
name: 'browser_navigate',
|
||||||
|
arguments: { url: server.PREFIX },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(await client.callTool({
|
||||||
|
name: 'browser_click',
|
||||||
|
arguments: {
|
||||||
|
element: 'Submit button',
|
||||||
|
ref: 'e2',
|
||||||
|
},
|
||||||
|
})).toHaveResponse({
|
||||||
|
code: `await page.getByRole('button', { name: 'Submit' }).click();`,
|
||||||
|
pageState: expect.stringContaining(`- button "Submit" ${mcpBrowser !== 'webkit' || process.platform === 'linux' ? '[active] ' : ''}[ref=e2]`),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('browser_click (double)', async ({ client, server }) => {
|
||||||
|
server.setContent('/', `
|
||||||
|
<title>Title</title>
|
||||||
|
<script>
|
||||||
|
function handle() {
|
||||||
|
document.querySelector('h1').textContent = 'Double clicked';
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
<h1 ondblclick="handle()">Click me</h1>
|
||||||
|
`, 'text/html');
|
||||||
|
|
||||||
|
await client.callTool({
|
||||||
|
name: 'browser_navigate',
|
||||||
|
arguments: { url: server.PREFIX },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(await client.callTool({
|
||||||
|
name: 'browser_click',
|
||||||
|
arguments: {
|
||||||
|
element: 'Click me',
|
||||||
|
ref: 'e2',
|
||||||
|
doubleClick: true,
|
||||||
|
},
|
||||||
|
})).toHaveResponse({
|
||||||
|
code: `await page.getByRole('heading', { name: 'Click me' }).dblclick();`,
|
||||||
|
pageState: expect.stringContaining(`- heading "Double clicked" [level=1] [ref=e3]`),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('browser_click (right)', async ({ client, server }) => {
|
||||||
|
server.setContent('/', `
|
||||||
|
<button oncontextmenu="handle">Menu</button>
|
||||||
|
<script>
|
||||||
|
document.addEventListener('contextmenu', event => {
|
||||||
|
event.preventDefault();
|
||||||
|
document.querySelector('button').textContent = 'Right clicked';
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
`, 'text/html');
|
||||||
|
|
||||||
|
await client.callTool({
|
||||||
|
name: 'browser_navigate',
|
||||||
|
arguments: { url: server.PREFIX },
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await client.callTool({
|
||||||
|
name: 'browser_click',
|
||||||
|
arguments: {
|
||||||
|
element: 'Menu',
|
||||||
|
ref: 'e2',
|
||||||
|
button: 'right',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(result).toHaveResponse({
|
||||||
|
code: `await page.getByRole('button', { name: 'Menu' }).click({ button: 'right' });`,
|
||||||
|
pageState: expect.stringContaining(`- button "Right clicked"`),
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -19,7 +19,7 @@ import fs from 'node:fs';
|
|||||||
import { Config } from '../config.js';
|
import { Config } from '../config.js';
|
||||||
import { test, expect } from './fixtures.js';
|
import { test, expect } from './fixtures.js';
|
||||||
|
|
||||||
test('config user data dir', async ({ startClient, server }, testInfo) => {
|
test('config user data dir', async ({ startClient, server, mcpMode }, testInfo) => {
|
||||||
server.setContent('/', `
|
server.setContent('/', `
|
||||||
<title>Title</title>
|
<title>Title</title>
|
||||||
<body>Hello, world!</body>
|
<body>Hello, world!</body>
|
||||||
@@ -37,7 +37,9 @@ test('config user data dir', async ({ startClient, server }, testInfo) => {
|
|||||||
expect(await client.callTool({
|
expect(await client.callTool({
|
||||||
name: 'browser_navigate',
|
name: 'browser_navigate',
|
||||||
arguments: { url: server.PREFIX },
|
arguments: { url: server.PREFIX },
|
||||||
})).toContainTextContent(`Hello, world!`);
|
})).toHaveResponse({
|
||||||
|
pageState: expect.stringContaining(`Hello, world!`),
|
||||||
|
});
|
||||||
|
|
||||||
const files = await fs.promises.readdir(config.browser!.userDataDir!);
|
const files = await fs.promises.readdir(config.browser!.userDataDir!);
|
||||||
expect(files.length).toBeGreaterThan(0);
|
expect(files.length).toBeGreaterThan(0);
|
||||||
@@ -45,7 +47,7 @@ test('config user data dir', async ({ startClient, server }, testInfo) => {
|
|||||||
|
|
||||||
test.describe(() => {
|
test.describe(() => {
|
||||||
test.use({ mcpBrowser: '' });
|
test.use({ mcpBrowser: '' });
|
||||||
test('browserName', { annotation: { type: 'issue', description: 'https://github.com/microsoft/playwright-mcp/issues/458' } }, async ({ startClient }, testInfo) => {
|
test('browserName', { annotation: { type: 'issue', description: 'https://github.com/microsoft/playwright-mcp/issues/458' } }, async ({ startClient, mcpMode }, testInfo) => {
|
||||||
const config: Config = {
|
const config: Config = {
|
||||||
browser: {
|
browser: {
|
||||||
browserName: 'firefox',
|
browserName: 'firefox',
|
||||||
@@ -58,6 +60,25 @@ test.describe(() => {
|
|||||||
expect(await client.callTool({
|
expect(await client.callTool({
|
||||||
name: 'browser_navigate',
|
name: 'browser_navigate',
|
||||||
arguments: { url: 'data:text/html,<script>document.title = navigator.userAgent</script>' },
|
arguments: { url: 'data:text/html,<script>document.title = navigator.userAgent</script>' },
|
||||||
})).toContainTextContent(`Firefox`);
|
})).toHaveResponse({
|
||||||
|
pageState: expect.stringContaining(`Firefox`),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test.describe('sandbox configuration', () => {
|
||||||
|
test('should enable sandbox by default (no --no-sandbox flag)', async () => {
|
||||||
|
const { configFromCLIOptions } = await import('../lib/config.js');
|
||||||
|
const config = configFromCLIOptions({ sandbox: undefined });
|
||||||
|
// When --no-sandbox is not passed, chromiumSandbox should not be set to false
|
||||||
|
// This allows the default (true) to be used
|
||||||
|
expect(config.browser?.launchOptions?.chromiumSandbox).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should disable sandbox when --no-sandbox flag is passed', async () => {
|
||||||
|
const { configFromCLIOptions } = await import('../lib/config.js');
|
||||||
|
const config = configFromCLIOptions({ sandbox: false });
|
||||||
|
// When --no-sandbox is passed, chromiumSandbox should be explicitly set to false
|
||||||
|
expect(config.browser?.launchOptions?.chromiumSandbox).toBe(false);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -37,8 +37,64 @@ test('browser_console_messages', async ({ client, server }) => {
|
|||||||
const resource = await client.callTool({
|
const resource = await client.callTool({
|
||||||
name: 'browser_console_messages',
|
name: 'browser_console_messages',
|
||||||
});
|
});
|
||||||
expect(resource).toHaveTextContent([
|
expect(resource).toHaveResponse({
|
||||||
'[LOG] Hello, world!',
|
result: `[LOG] Hello, world! @ ${server.PREFIX}:4
|
||||||
'[ERROR] Error',
|
[ERROR] Error @ ${server.PREFIX}:5`,
|
||||||
].join('\n'));
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('browser_console_messages (page error)', async ({ client, server }) => {
|
||||||
|
server.setContent('/', `
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<script>
|
||||||
|
throw new Error("Error in script");
|
||||||
|
</script>
|
||||||
|
</html>
|
||||||
|
`, 'text/html');
|
||||||
|
|
||||||
|
await client.callTool({
|
||||||
|
name: 'browser_navigate',
|
||||||
|
arguments: {
|
||||||
|
url: server.PREFIX,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const resource = await client.callTool({
|
||||||
|
name: 'browser_console_messages',
|
||||||
|
});
|
||||||
|
expect(resource).toHaveResponse({
|
||||||
|
result: expect.stringContaining(`Error: Error in script`),
|
||||||
|
});
|
||||||
|
expect(resource).toHaveResponse({
|
||||||
|
result: expect.stringContaining(server.PREFIX),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('recent console messages', async ({ client, server }) => {
|
||||||
|
server.setContent('/', `
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<button onclick="console.log('Hello, world!');">Click me</button>
|
||||||
|
</html>
|
||||||
|
`, 'text/html');
|
||||||
|
|
||||||
|
await client.callTool({
|
||||||
|
name: 'browser_navigate',
|
||||||
|
arguments: {
|
||||||
|
url: server.PREFIX,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const response = await client.callTool({
|
||||||
|
name: 'browser_click',
|
||||||
|
arguments: {
|
||||||
|
element: 'Click me',
|
||||||
|
ref: 'e2',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response).toHaveResponse({
|
||||||
|
consoleMessages: expect.stringContaining(`- [LOG] Hello, world! @`),
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -20,54 +20,15 @@ test('browser_navigate', async ({ client, server }) => {
|
|||||||
expect(await client.callTool({
|
expect(await client.callTool({
|
||||||
name: 'browser_navigate',
|
name: 'browser_navigate',
|
||||||
arguments: { url: server.HELLO_WORLD },
|
arguments: { url: server.HELLO_WORLD },
|
||||||
})).toHaveTextContent(`
|
})).toHaveResponse({
|
||||||
- Ran Playwright code:
|
code: `await page.goto('${server.HELLO_WORLD}');`,
|
||||||
\`\`\`js
|
pageState: `- Page URL: ${server.HELLO_WORLD}
|
||||||
// Navigate to ${server.HELLO_WORLD}
|
|
||||||
await page.goto('${server.HELLO_WORLD}');
|
|
||||||
\`\`\`
|
|
||||||
|
|
||||||
- Page URL: ${server.HELLO_WORLD}
|
|
||||||
- Page Title: Title
|
- Page Title: Title
|
||||||
- Page Snapshot
|
- Page Snapshot:
|
||||||
\`\`\`yaml
|
\`\`\`yaml
|
||||||
- generic [ref=e1]: Hello, world!
|
- generic [active] [ref=e1]: Hello, world!
|
||||||
\`\`\`
|
\`\`\``,
|
||||||
`
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('browser_click', async ({ client, server }) => {
|
|
||||||
server.setContent('/', `
|
|
||||||
<title>Title</title>
|
|
||||||
<button>Submit</button>
|
|
||||||
`, 'text/html');
|
|
||||||
|
|
||||||
await client.callTool({
|
|
||||||
name: 'browser_navigate',
|
|
||||||
arguments: { url: server.PREFIX },
|
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(await client.callTool({
|
|
||||||
name: 'browser_click',
|
|
||||||
arguments: {
|
|
||||||
element: 'Submit button',
|
|
||||||
ref: 'e2',
|
|
||||||
},
|
|
||||||
})).toHaveTextContent(`
|
|
||||||
- Ran Playwright code:
|
|
||||||
\`\`\`js
|
|
||||||
// Click Submit button
|
|
||||||
await page.getByRole('button', { name: 'Submit' }).click();
|
|
||||||
\`\`\`
|
|
||||||
|
|
||||||
- Page URL: ${server.PREFIX}
|
|
||||||
- Page Title: Title
|
|
||||||
- Page Snapshot
|
|
||||||
\`\`\`yaml
|
|
||||||
- button "Submit" [ref=e2]
|
|
||||||
\`\`\`
|
|
||||||
`);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test('browser_select_option', async ({ client, server }) => {
|
test('browser_select_option', async ({ client, server }) => {
|
||||||
@@ -91,22 +52,17 @@ test('browser_select_option', async ({ client, server }) => {
|
|||||||
ref: 'e2',
|
ref: 'e2',
|
||||||
values: ['bar'],
|
values: ['bar'],
|
||||||
},
|
},
|
||||||
})).toHaveTextContent(`
|
})).toHaveResponse({
|
||||||
- Ran Playwright code:
|
code: `await page.getByRole('combobox').selectOption(['bar']);`,
|
||||||
\`\`\`js
|
pageState: `- Page URL: ${server.PREFIX}
|
||||||
// Select options [bar] in Select
|
|
||||||
await page.getByRole('combobox').selectOption(['bar']);
|
|
||||||
\`\`\`
|
|
||||||
|
|
||||||
- Page URL: ${server.PREFIX}
|
|
||||||
- Page Title: Title
|
- Page Title: Title
|
||||||
- Page Snapshot
|
- Page Snapshot:
|
||||||
\`\`\`yaml
|
\`\`\`yaml
|
||||||
- combobox [ref=e2]:
|
- combobox [ref=e2]:
|
||||||
- option "Foo"
|
- option "Foo"
|
||||||
- option "Bar" [selected]
|
- option "Bar" [selected]
|
||||||
\`\`\`
|
\`\`\``,
|
||||||
`);
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
test('browser_select_option (multiple)', async ({ client, server }) => {
|
test('browser_select_option (multiple)', async ({ client, server }) => {
|
||||||
@@ -131,82 +87,14 @@ test('browser_select_option (multiple)', async ({ client, server }) => {
|
|||||||
ref: 'e2',
|
ref: 'e2',
|
||||||
values: ['bar', 'baz'],
|
values: ['bar', 'baz'],
|
||||||
},
|
},
|
||||||
})).toHaveTextContent(`
|
})).toHaveResponse({
|
||||||
- Ran Playwright code:
|
code: `await page.getByRole('listbox').selectOption(['bar', 'baz']);`,
|
||||||
\`\`\`js
|
pageState: expect.stringContaining(`
|
||||||
// Select options [bar, baz] in Select
|
|
||||||
await page.getByRole('listbox').selectOption(['bar', 'baz']);
|
|
||||||
\`\`\`
|
|
||||||
|
|
||||||
- Page URL: ${server.PREFIX}
|
|
||||||
- Page Title: Title
|
|
||||||
- Page Snapshot
|
|
||||||
\`\`\`yaml
|
|
||||||
- listbox [ref=e2]:
|
- listbox [ref=e2]:
|
||||||
- option "Foo" [ref=e3]
|
- option "Foo" [ref=e3]
|
||||||
- option "Bar" [selected] [ref=e4]
|
- option "Bar" [selected] [ref=e4]
|
||||||
- option "Baz" [selected] [ref=e5]
|
- option "Baz" [selected] [ref=e5]`),
|
||||||
\`\`\`
|
|
||||||
`);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('browser_type', async ({ client, server }) => {
|
|
||||||
server.setContent('/', `
|
|
||||||
<!DOCTYPE html>
|
|
||||||
<html>
|
|
||||||
<input type='keypress' onkeypress="console.log('Key pressed:', event.key, ', Text:', event.target.value)"></input>
|
|
||||||
</html>
|
|
||||||
`, 'text/html');
|
|
||||||
|
|
||||||
await client.callTool({
|
|
||||||
name: 'browser_navigate',
|
|
||||||
arguments: {
|
|
||||||
url: server.PREFIX,
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
await client.callTool({
|
|
||||||
name: 'browser_type',
|
|
||||||
arguments: {
|
|
||||||
element: 'textbox',
|
|
||||||
ref: 'e2',
|
|
||||||
text: 'Hi!',
|
|
||||||
submit: true,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
expect(await client.callTool({
|
|
||||||
name: 'browser_console_messages',
|
|
||||||
})).toHaveTextContent('[LOG] Key pressed: Enter , Text: Hi!');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('browser_type (slowly)', async ({ client, server }) => {
|
|
||||||
server.setContent('/', `
|
|
||||||
<input type='text' onkeydown="console.log('Key pressed:', event.key, 'Text:', event.target.value)"></input>
|
|
||||||
`, 'text/html');
|
|
||||||
|
|
||||||
await client.callTool({
|
|
||||||
name: 'browser_navigate',
|
|
||||||
arguments: {
|
|
||||||
url: server.PREFIX,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
await client.callTool({
|
|
||||||
name: 'browser_type',
|
|
||||||
arguments: {
|
|
||||||
element: 'textbox',
|
|
||||||
ref: 'e2',
|
|
||||||
text: 'Hi!',
|
|
||||||
submit: true,
|
|
||||||
slowly: true,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
expect(await client.callTool({
|
|
||||||
name: 'browser_console_messages',
|
|
||||||
})).toHaveTextContent([
|
|
||||||
'[LOG] Key pressed: H Text: ',
|
|
||||||
'[LOG] Key pressed: i Text: H',
|
|
||||||
'[LOG] Key pressed: ! Text: Hi',
|
|
||||||
'[LOG] Key pressed: Enter Text: Hi!',
|
|
||||||
].join('\n'));
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test('browser_resize', async ({ client, server }) => {
|
test('browser_resize', async ({ client, server }) => {
|
||||||
@@ -230,10 +118,73 @@ test('browser_resize', async ({ client, server }) => {
|
|||||||
height: 780,
|
height: 780,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
expect(response).toContainTextContent(`- Ran Playwright code:
|
expect(response).toHaveResponse({
|
||||||
\`\`\`js
|
code: `await page.setViewportSize({ width: 390, height: 780 });`,
|
||||||
// Resize browser window to 390x780
|
});
|
||||||
await page.setViewportSize({ width: 390, height: 780 });
|
await expect.poll(() => client.callTool({ name: 'browser_snapshot' })).toHaveResponse({
|
||||||
\`\`\``);
|
pageState: expect.stringContaining(`Window size: 390x780`),
|
||||||
await expect.poll(() => client.callTool({ name: 'browser_snapshot' })).toContainTextContent('Window size: 390x780');
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('old locator error message', async ({ client, server }) => {
|
||||||
|
server.setContent('/', `
|
||||||
|
<button>Button 1</button>
|
||||||
|
<button>Button 2</button>
|
||||||
|
<script>
|
||||||
|
document.querySelector('button').addEventListener('click', () => {
|
||||||
|
document.querySelectorAll('button')[1].remove();
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
`, 'text/html');
|
||||||
|
|
||||||
|
expect(await client.callTool({
|
||||||
|
name: 'browser_navigate',
|
||||||
|
arguments: {
|
||||||
|
url: server.PREFIX,
|
||||||
|
},
|
||||||
|
})).toHaveResponse({
|
||||||
|
pageState: expect.stringContaining(`
|
||||||
|
- button "Button 1" [ref=e2]
|
||||||
|
- button "Button 2" [ref=e3]`),
|
||||||
|
});
|
||||||
|
|
||||||
|
await client.callTool({
|
||||||
|
name: 'browser_click',
|
||||||
|
arguments: {
|
||||||
|
element: 'Button 1',
|
||||||
|
ref: 'e2',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(await client.callTool({
|
||||||
|
name: 'browser_click',
|
||||||
|
arguments: {
|
||||||
|
element: 'Button 2',
|
||||||
|
ref: 'e3',
|
||||||
|
},
|
||||||
|
})).toHaveResponse({
|
||||||
|
result: expect.stringContaining(`Ref e3 not found in the current page snapshot. Try capturing new snapshot.`),
|
||||||
|
isError: true,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('visibility: hidden > visible should be shown', { annotation: { type: 'issue', description: 'https://github.com/microsoft/playwright-mcp/issues/535' } }, async ({ client, server }) => {
|
||||||
|
server.setContent('/', `
|
||||||
|
<div style="visibility: hidden;">
|
||||||
|
<div style="visibility: visible;">
|
||||||
|
<button>Button</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`, 'text/html');
|
||||||
|
|
||||||
|
await client.callTool({
|
||||||
|
name: 'browser_navigate',
|
||||||
|
arguments: { url: server.PREFIX },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(await client.callTool({
|
||||||
|
name: 'browser_snapshot'
|
||||||
|
})).toHaveResponse({
|
||||||
|
pageState: expect.stringContaining(`- button "Button"`),
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -16,7 +16,7 @@
|
|||||||
|
|
||||||
import { test, expect } from './fixtures.js';
|
import { test, expect } from './fixtures.js';
|
||||||
|
|
||||||
test('--device should work', async ({ startClient, server }) => {
|
test('--device should work', async ({ startClient, server, mcpMode }) => {
|
||||||
const { client } = await startClient({
|
const { client } = await startClient({
|
||||||
args: ['--device', 'iPhone 15'],
|
args: ['--device', 'iPhone 15'],
|
||||||
});
|
});
|
||||||
@@ -39,5 +39,7 @@ test('--device should work', async ({ startClient, server }) => {
|
|||||||
arguments: {
|
arguments: {
|
||||||
url: server.PREFIX,
|
url: server.PREFIX,
|
||||||
},
|
},
|
||||||
})).toContainTextContent(`393x659`);
|
})).toHaveResponse({
|
||||||
|
pageState: expect.stringContaining(`393x659`),
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -16,15 +16,14 @@
|
|||||||
|
|
||||||
import { test, expect } from './fixtures.js';
|
import { test, expect } from './fixtures.js';
|
||||||
|
|
||||||
// https://github.com/microsoft/playwright/issues/35663
|
|
||||||
test.skip(({ mcpBrowser, mcpHeadless }) => mcpBrowser === 'webkit' && mcpHeadless);
|
|
||||||
|
|
||||||
test('alert dialog', async ({ client, server }) => {
|
test('alert dialog', async ({ client, server }) => {
|
||||||
server.setContent('/', `<button onclick="alert('Alert')">Button</button>`, 'text/html');
|
server.setContent('/', `<button onclick="alert('Alert')">Button</button>`, 'text/html');
|
||||||
expect(await client.callTool({
|
expect(await client.callTool({
|
||||||
name: 'browser_navigate',
|
name: 'browser_navigate',
|
||||||
arguments: { url: server.PREFIX },
|
arguments: { url: server.PREFIX },
|
||||||
})).toContainTextContent('- button "Button" [ref=e2]');
|
})).toHaveResponse({
|
||||||
|
pageState: expect.stringContaining(`- button "Button" [ref=e2]`),
|
||||||
|
});
|
||||||
|
|
||||||
expect(await client.callTool({
|
expect(await client.callTool({
|
||||||
name: 'browser_click',
|
name: 'browser_click',
|
||||||
@@ -32,40 +31,34 @@ test('alert dialog', async ({ client, server }) => {
|
|||||||
element: 'Button',
|
element: 'Button',
|
||||||
ref: 'e2',
|
ref: 'e2',
|
||||||
},
|
},
|
||||||
})).toHaveTextContent(`- Ran Playwright code:
|
})).toHaveResponse({
|
||||||
\`\`\`js
|
code: `await page.getByRole('button', { name: 'Button' }).click();`,
|
||||||
// Click Button
|
modalState: `- ["alert" dialog with message "Alert"]: can be handled by the "browser_handle_dialog" tool`,
|
||||||
await page.getByRole('button', { name: 'Button' }).click();
|
});
|
||||||
\`\`\`
|
|
||||||
|
|
||||||
### Modal state
|
expect(await client.callTool({
|
||||||
- ["alert" dialog with message "Alert"]: can be handled by the "browser_handle_dialog" tool`);
|
name: 'browser_click',
|
||||||
|
arguments: {
|
||||||
|
element: 'Button',
|
||||||
|
ref: 'e2',
|
||||||
|
},
|
||||||
|
})).toHaveResponse({
|
||||||
|
code: undefined,
|
||||||
|
modalState: `- ["alert" dialog with message "Alert"]: can be handled by the "browser_handle_dialog" tool`,
|
||||||
|
});
|
||||||
|
|
||||||
const result = await client.callTool({
|
expect(await client.callTool({
|
||||||
name: 'browser_handle_dialog',
|
name: 'browser_handle_dialog',
|
||||||
arguments: {
|
arguments: {
|
||||||
accept: true,
|
accept: true,
|
||||||
},
|
},
|
||||||
|
})).toHaveResponse({
|
||||||
|
modalState: undefined,
|
||||||
|
pageState: expect.stringContaining(`- button "Button"`),
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(result).not.toContainTextContent('### Modal state');
|
|
||||||
expect(result).toHaveTextContent(`- Ran Playwright code:
|
|
||||||
\`\`\`js
|
|
||||||
// <internal code to handle "alert" dialog>
|
|
||||||
\`\`\`
|
|
||||||
|
|
||||||
- Page URL: ${server.PREFIX}
|
|
||||||
- Page Title:
|
|
||||||
- Page Snapshot
|
|
||||||
\`\`\`yaml
|
|
||||||
- button "Button" [ref=e2]
|
|
||||||
\`\`\`
|
|
||||||
`);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test('two alert dialogs', async ({ client, server }) => {
|
test('two alert dialogs', async ({ client, server }) => {
|
||||||
test.fixme(true, 'Race between the dialog and ariaSnapshot');
|
|
||||||
|
|
||||||
server.setContent('/', `
|
server.setContent('/', `
|
||||||
<title>Title</title>
|
<title>Title</title>
|
||||||
<body>
|
<body>
|
||||||
@@ -76,7 +69,9 @@ test('two alert dialogs', async ({ client, server }) => {
|
|||||||
expect(await client.callTool({
|
expect(await client.callTool({
|
||||||
name: 'browser_navigate',
|
name: 'browser_navigate',
|
||||||
arguments: { url: server.PREFIX },
|
arguments: { url: server.PREFIX },
|
||||||
})).toContainTextContent('- button "Button" [ref=e2]');
|
})).toHaveResponse({
|
||||||
|
pageState: expect.stringContaining(`- button "Button" [ref=e2]`),
|
||||||
|
});
|
||||||
|
|
||||||
expect(await client.callTool({
|
expect(await client.callTool({
|
||||||
name: 'browser_click',
|
name: 'browser_click',
|
||||||
@@ -84,14 +79,10 @@ test('two alert dialogs', async ({ client, server }) => {
|
|||||||
element: 'Button',
|
element: 'Button',
|
||||||
ref: 'e2',
|
ref: 'e2',
|
||||||
},
|
},
|
||||||
})).toHaveTextContent(`- Ran Playwright code:
|
})).toHaveResponse({
|
||||||
\`\`\`js
|
code: `await page.getByRole('button', { name: 'Button' }).click();`,
|
||||||
// Click Button
|
modalState: expect.stringContaining(`- ["alert" dialog with message "Alert 1"]: can be handled by the "browser_handle_dialog" tool`),
|
||||||
await page.getByRole('button', { name: 'Button' }).click();
|
});
|
||||||
\`\`\`
|
|
||||||
|
|
||||||
### Modal state
|
|
||||||
- ["alert" dialog with message "Alert 1"]: can be handled by the "browser_handle_dialog" tool`);
|
|
||||||
|
|
||||||
const result = await client.callTool({
|
const result = await client.callTool({
|
||||||
name: 'browser_handle_dialog',
|
name: 'browser_handle_dialog',
|
||||||
@@ -100,7 +91,20 @@ await page.getByRole('button', { name: 'Button' }).click();
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(result).not.toContainTextContent('### Modal state');
|
expect(result).toHaveResponse({
|
||||||
|
modalState: expect.stringContaining(`- ["alert" dialog with message "Alert 2"]: can be handled by the "browser_handle_dialog" tool`),
|
||||||
|
});
|
||||||
|
|
||||||
|
const result2 = await client.callTool({
|
||||||
|
name: 'browser_handle_dialog',
|
||||||
|
arguments: {
|
||||||
|
accept: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result2).not.toHaveResponse({
|
||||||
|
modalState: expect.stringContaining(`- ["alert" dialog with message "Alert 2"]: can be handled by the "browser_handle_dialog" tool`),
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
test('confirm dialog (true)', async ({ client, server }) => {
|
test('confirm dialog (true)', async ({ client, server }) => {
|
||||||
@@ -114,7 +118,9 @@ test('confirm dialog (true)', async ({ client, server }) => {
|
|||||||
expect(await client.callTool({
|
expect(await client.callTool({
|
||||||
name: 'browser_navigate',
|
name: 'browser_navigate',
|
||||||
arguments: { url: server.PREFIX },
|
arguments: { url: server.PREFIX },
|
||||||
})).toContainTextContent('- button "Button" [ref=e2]');
|
})).toHaveResponse({
|
||||||
|
pageState: expect.stringContaining(`- button "Button" [ref=e2]`),
|
||||||
|
});
|
||||||
|
|
||||||
expect(await client.callTool({
|
expect(await client.callTool({
|
||||||
name: 'browser_click',
|
name: 'browser_click',
|
||||||
@@ -122,22 +128,19 @@ test('confirm dialog (true)', async ({ client, server }) => {
|
|||||||
element: 'Button',
|
element: 'Button',
|
||||||
ref: 'e2',
|
ref: 'e2',
|
||||||
},
|
},
|
||||||
})).toContainTextContent(`### Modal state
|
})).toHaveResponse({
|
||||||
- ["confirm" dialog with message "Confirm"]: can be handled by the "browser_handle_dialog" tool`);
|
modalState: expect.stringContaining(`- ["confirm" dialog with message "Confirm"]: can be handled by the "browser_handle_dialog" tool`),
|
||||||
|
});
|
||||||
|
|
||||||
const result = await client.callTool({
|
expect(await client.callTool({
|
||||||
name: 'browser_handle_dialog',
|
name: 'browser_handle_dialog',
|
||||||
arguments: {
|
arguments: {
|
||||||
accept: true,
|
accept: true,
|
||||||
},
|
},
|
||||||
|
})).toHaveResponse({
|
||||||
|
modalState: undefined,
|
||||||
|
pageState: expect.stringContaining(`- generic [active] [ref=e1]: "true"`),
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(result).not.toContainTextContent('### Modal state');
|
|
||||||
expect(result).toContainTextContent('// <internal code to handle "confirm" dialog>');
|
|
||||||
expect(result).toContainTextContent(`- Page Snapshot
|
|
||||||
\`\`\`yaml
|
|
||||||
- generic [ref=e1]: "true"
|
|
||||||
\`\`\``);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test('confirm dialog (false)', async ({ client, server }) => {
|
test('confirm dialog (false)', async ({ client, server }) => {
|
||||||
@@ -151,7 +154,9 @@ test('confirm dialog (false)', async ({ client, server }) => {
|
|||||||
expect(await client.callTool({
|
expect(await client.callTool({
|
||||||
name: 'browser_navigate',
|
name: 'browser_navigate',
|
||||||
arguments: { url: server.PREFIX },
|
arguments: { url: server.PREFIX },
|
||||||
})).toContainTextContent('- button "Button" [ref=e2]');
|
})).toHaveResponse({
|
||||||
|
pageState: expect.stringContaining(`- button "Button" [ref=e2]`),
|
||||||
|
});
|
||||||
|
|
||||||
expect(await client.callTool({
|
expect(await client.callTool({
|
||||||
name: 'browser_click',
|
name: 'browser_click',
|
||||||
@@ -159,20 +164,19 @@ test('confirm dialog (false)', async ({ client, server }) => {
|
|||||||
element: 'Button',
|
element: 'Button',
|
||||||
ref: 'e2',
|
ref: 'e2',
|
||||||
},
|
},
|
||||||
})).toContainTextContent(`### Modal state
|
})).toHaveResponse({
|
||||||
- ["confirm" dialog with message "Confirm"]: can be handled by the "browser_handle_dialog" tool`);
|
modalState: expect.stringContaining(`- ["confirm" dialog with message "Confirm"]: can be handled by the "browser_handle_dialog" tool`),
|
||||||
|
});
|
||||||
|
|
||||||
const result = await client.callTool({
|
expect(await client.callTool({
|
||||||
name: 'browser_handle_dialog',
|
name: 'browser_handle_dialog',
|
||||||
arguments: {
|
arguments: {
|
||||||
accept: false,
|
accept: false,
|
||||||
},
|
},
|
||||||
|
})).toHaveResponse({
|
||||||
|
modalState: undefined,
|
||||||
|
pageState: expect.stringContaining(`- generic [active] [ref=e1]: "false"`),
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(result).toContainTextContent(`- Page Snapshot
|
|
||||||
\`\`\`yaml
|
|
||||||
- generic [ref=e1]: "false"
|
|
||||||
\`\`\``);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test('prompt dialog', async ({ client, server }) => {
|
test('prompt dialog', async ({ client, server }) => {
|
||||||
@@ -186,7 +190,9 @@ test('prompt dialog', async ({ client, server }) => {
|
|||||||
expect(await client.callTool({
|
expect(await client.callTool({
|
||||||
name: 'browser_navigate',
|
name: 'browser_navigate',
|
||||||
arguments: { url: server.PREFIX },
|
arguments: { url: server.PREFIX },
|
||||||
})).toContainTextContent('- button "Button" [ref=e2]');
|
})).toHaveResponse({
|
||||||
|
pageState: expect.stringContaining(`- button "Button" [ref=e2]`),
|
||||||
|
});
|
||||||
|
|
||||||
expect(await client.callTool({
|
expect(await client.callTool({
|
||||||
name: 'browser_click',
|
name: 'browser_click',
|
||||||
@@ -194,8 +200,9 @@ test('prompt dialog', async ({ client, server }) => {
|
|||||||
element: 'Button',
|
element: 'Button',
|
||||||
ref: 'e2',
|
ref: 'e2',
|
||||||
},
|
},
|
||||||
})).toContainTextContent(`### Modal state
|
})).toHaveResponse({
|
||||||
- ["prompt" dialog with message "Prompt"]: can be handled by the "browser_handle_dialog" tool`);
|
modalState: expect.stringContaining(`- ["prompt" dialog with message "Prompt"]: can be handled by the "browser_handle_dialog" tool`),
|
||||||
|
});
|
||||||
|
|
||||||
const result = await client.callTool({
|
const result = await client.callTool({
|
||||||
name: 'browser_handle_dialog',
|
name: 'browser_handle_dialog',
|
||||||
@@ -205,8 +212,44 @@ test('prompt dialog', async ({ client, server }) => {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(result).toContainTextContent(`- Page Snapshot
|
expect(result).toHaveResponse({
|
||||||
\`\`\`yaml
|
pageState: expect.stringContaining(`- generic [active] [ref=e1]: Answer`),
|
||||||
- generic [ref=e1]: Answer
|
});
|
||||||
\`\`\``);
|
});
|
||||||
|
|
||||||
|
test('alert dialog w/ race', async ({ client, server }) => {
|
||||||
|
server.setContent('/', `<button onclick="setTimeout(() => alert('Alert'), 100)">Button</button>`, 'text/html');
|
||||||
|
expect(await client.callTool({
|
||||||
|
name: 'browser_navigate',
|
||||||
|
arguments: { url: server.PREFIX },
|
||||||
|
})).toHaveResponse({
|
||||||
|
pageState: expect.stringContaining(`- button "Button" [ref=e2]`),
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(await client.callTool({
|
||||||
|
name: 'browser_click',
|
||||||
|
arguments: {
|
||||||
|
element: 'Button',
|
||||||
|
ref: 'e2',
|
||||||
|
},
|
||||||
|
})).toHaveResponse({
|
||||||
|
code: `await page.getByRole('button', { name: 'Button' }).click();`,
|
||||||
|
modalState: expect.stringContaining(`- ["alert" dialog with message "Alert"]: can be handled by the "browser_handle_dialog" tool`),
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await client.callTool({
|
||||||
|
name: 'browser_handle_dialog',
|
||||||
|
arguments: {
|
||||||
|
accept: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result).toHaveResponse({
|
||||||
|
modalState: undefined,
|
||||||
|
pageState: expect.stringContaining(`- Page URL: ${server.PREFIX}
|
||||||
|
- Page Title:
|
||||||
|
- Page Snapshot:
|
||||||
|
\`\`\`yaml
|
||||||
|
- button "Button"`),
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
80
tests/evaluate.spec.ts
Normal file
80
tests/evaluate.spec.ts
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
/**
|
||||||
|
* 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_evaluate', async ({ client, server }) => {
|
||||||
|
expect(await client.callTool({
|
||||||
|
name: 'browser_navigate',
|
||||||
|
arguments: { url: server.HELLO_WORLD },
|
||||||
|
})).toHaveResponse({
|
||||||
|
pageState: expect.stringContaining(`- Page Title: Title`),
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(await client.callTool({
|
||||||
|
name: 'browser_evaluate',
|
||||||
|
arguments: {
|
||||||
|
function: '() => document.title',
|
||||||
|
},
|
||||||
|
})).toHaveResponse({
|
||||||
|
result: `"Title"`,
|
||||||
|
code: `await page.evaluate('() => document.title');`,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('browser_evaluate (element)', async ({ client, server }) => {
|
||||||
|
server.setContent('/', `
|
||||||
|
<body style="background-color: red">Hello, world!</body>
|
||||||
|
`, 'text/html');
|
||||||
|
await client.callTool({
|
||||||
|
name: 'browser_navigate',
|
||||||
|
arguments: { url: server.PREFIX },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(await client.callTool({
|
||||||
|
name: 'browser_evaluate',
|
||||||
|
arguments: {
|
||||||
|
function: 'element => element.style.backgroundColor',
|
||||||
|
element: 'body',
|
||||||
|
ref: 'e1',
|
||||||
|
},
|
||||||
|
})).toHaveResponse({
|
||||||
|
result: `"red"`,
|
||||||
|
code: `await page.getByText('Hello, world!').evaluate('element => element.style.backgroundColor');`,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('browser_evaluate (error)', async ({ client, server }) => {
|
||||||
|
expect(await client.callTool({
|
||||||
|
name: 'browser_navigate',
|
||||||
|
arguments: { url: server.HELLO_WORLD },
|
||||||
|
})).toHaveResponse({
|
||||||
|
pageState: expect.stringContaining(`- Page Title: Title`),
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await client.callTool({
|
||||||
|
name: 'browser_evaluate',
|
||||||
|
arguments: {
|
||||||
|
function: '() => nonExistentVariable',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.isError).toBe(true);
|
||||||
|
expect(result.content?.[0]?.text).toContain('nonExistentVariable');
|
||||||
|
// Check for common error patterns across browsers
|
||||||
|
const errorText = result.content?.[0]?.text || '';
|
||||||
|
expect(errorText).toMatch(/not defined|Can't find variable/);
|
||||||
|
});
|
||||||
@@ -14,8 +14,8 @@
|
|||||||
* limitations under the License.
|
* limitations under the License.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { test, expect } from './fixtures.js';
|
|
||||||
import fs from 'fs/promises';
|
import fs from 'fs/promises';
|
||||||
|
import { test, expect } from './fixtures.js';
|
||||||
|
|
||||||
test('browser_file_upload', async ({ client, server }, testInfo) => {
|
test('browser_file_upload', async ({ client, server }, testInfo) => {
|
||||||
server.setContent('/', `
|
server.setContent('/', `
|
||||||
@@ -26,22 +26,21 @@ test('browser_file_upload', async ({ client, server }, testInfo) => {
|
|||||||
expect(await client.callTool({
|
expect(await client.callTool({
|
||||||
name: 'browser_navigate',
|
name: 'browser_navigate',
|
||||||
arguments: { url: server.PREFIX },
|
arguments: { url: server.PREFIX },
|
||||||
})).toContainTextContent(`
|
})).toHaveResponse({
|
||||||
\`\`\`yaml
|
pageState: expect.stringContaining(`- generic [active] [ref=e1]:
|
||||||
- generic [ref=e1]:
|
|
||||||
- button "Choose File" [ref=e2]
|
- button "Choose File" [ref=e2]
|
||||||
- button "Button" [ref=e3]
|
- button "Button" [ref=e3]`),
|
||||||
\`\`\``);
|
});
|
||||||
|
|
||||||
{
|
{
|
||||||
expect(await client.callTool({
|
expect(await client.callTool({
|
||||||
name: 'browser_file_upload',
|
name: 'browser_file_upload',
|
||||||
arguments: { paths: [] },
|
arguments: { paths: [] },
|
||||||
})).toHaveTextContent(`
|
})).toHaveResponse({
|
||||||
The tool "browser_file_upload" can only be used when there is related modal state present.
|
isError: true,
|
||||||
### Modal state
|
result: expect.stringContaining(`The tool "browser_file_upload" can only be used when there is related modal state present.`),
|
||||||
- There is no modal state present
|
modalState: expect.stringContaining(`- There is no modal state present`),
|
||||||
`.trim());
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
expect(await client.callTool({
|
expect(await client.callTool({
|
||||||
@@ -50,8 +49,9 @@ The tool "browser_file_upload" can only be used when there is related modal stat
|
|||||||
element: 'Textbox',
|
element: 'Textbox',
|
||||||
ref: 'e2',
|
ref: 'e2',
|
||||||
},
|
},
|
||||||
})).toContainTextContent(`### Modal state
|
})).toHaveResponse({
|
||||||
- [File chooser]: can be handled by the "browser_file_upload" tool`);
|
modalState: expect.stringContaining(`- [File chooser]: can be handled by the "browser_file_upload" tool`),
|
||||||
|
});
|
||||||
|
|
||||||
const filePath = testInfo.outputPath('test.txt');
|
const filePath = testInfo.outputPath('test.txt');
|
||||||
await fs.writeFile(filePath, 'Hello, world!');
|
await fs.writeFile(filePath, 'Hello, world!');
|
||||||
@@ -64,13 +64,10 @@ The tool "browser_file_upload" can only be used when there is related modal stat
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(response).not.toContainTextContent('### Modal state');
|
expect(response).toHaveResponse({
|
||||||
expect(response).toContainTextContent(`
|
code: expect.stringContaining(`await fileChooser.setFiles(`),
|
||||||
\`\`\`yaml
|
modalState: undefined,
|
||||||
- generic [ref=e1]:
|
});
|
||||||
- button "Choose File" [ref=e2]
|
|
||||||
- button "Button" [ref=e3]
|
|
||||||
\`\`\``);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
{
|
{
|
||||||
@@ -82,7 +79,9 @@ The tool "browser_file_upload" can only be used when there is related modal stat
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(response).toContainTextContent('- [File chooser]: can be handled by the \"browser_file_upload\" tool');
|
expect(response).toHaveResponse({
|
||||||
|
modalState: `- [File chooser]: can be handled by the "browser_file_upload" tool`,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
{
|
{
|
||||||
@@ -94,13 +93,14 @@ The tool "browser_file_upload" can only be used when there is related modal stat
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(response).toContainTextContent(`Tool "browser_click" does not handle the modal state.
|
expect(response).toHaveResponse({
|
||||||
### Modal state
|
result: `Error: Tool "browser_click" does not handle the modal state.`,
|
||||||
- [File chooser]: can be handled by the "browser_file_upload" tool`);
|
modalState: expect.stringContaining(`- [File chooser]: can be handled by the "browser_file_upload" tool`),
|
||||||
|
});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
test('clicking on download link emits download', async ({ startClient, server }, testInfo) => {
|
test('clicking on download link emits download', async ({ startClient, server, mcpMode }, testInfo) => {
|
||||||
const { client } = await startClient({
|
const { client } = await startClient({
|
||||||
config: { outputDir: testInfo.outputPath('output') },
|
config: { outputDir: testInfo.outputPath('output') },
|
||||||
});
|
});
|
||||||
@@ -111,7 +111,9 @@ test('clicking on download link emits download', async ({ startClient, server },
|
|||||||
expect(await client.callTool({
|
expect(await client.callTool({
|
||||||
name: 'browser_navigate',
|
name: 'browser_navigate',
|
||||||
arguments: { url: server.PREFIX },
|
arguments: { url: server.PREFIX },
|
||||||
})).toContainTextContent('- link "Download" [ref=e2]');
|
})).toHaveResponse({
|
||||||
|
pageState: expect.stringContaining(`- link "Download" [ref=e2]`),
|
||||||
|
});
|
||||||
await client.callTool({
|
await client.callTool({
|
||||||
name: 'browser_click',
|
name: 'browser_click',
|
||||||
arguments: {
|
arguments: {
|
||||||
@@ -119,17 +121,17 @@ test('clicking on download link emits download', async ({ startClient, server },
|
|||||||
ref: 'e2',
|
ref: 'e2',
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
await expect.poll(() => client.callTool({ name: 'browser_snapshot' })).toContainTextContent(`
|
await expect.poll(() => client.callTool({ name: 'browser_snapshot' })).toHaveResponse({
|
||||||
### Downloads
|
downloads: `- Downloaded file test.txt to ${testInfo.outputPath('output', 'test.txt')}`,
|
||||||
- Downloaded file test.txt to ${testInfo.outputPath('output', 'test.txt')}`);
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
test('navigating to download link emits download', async ({ startClient, server, mcpBrowser }, testInfo) => {
|
test('navigating to download link emits download', async ({ startClient, server, mcpBrowser, mcpMode }, testInfo) => {
|
||||||
const { client } = await startClient({
|
const { client } = await startClient({
|
||||||
config: { outputDir: testInfo.outputPath('output') },
|
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');
|
test.skip(mcpBrowser !== 'chromium', 'This test is racy');
|
||||||
server.route('/download', (req, res) => {
|
server.route('/download', (req, res) => {
|
||||||
res.writeHead(200, {
|
res.writeHead(200, {
|
||||||
'Content-Type': 'text/plain',
|
'Content-Type': 'text/plain',
|
||||||
@@ -143,5 +145,7 @@ test('navigating to download link emits download', async ({ startClient, server,
|
|||||||
arguments: {
|
arguments: {
|
||||||
url: server.PREFIX + 'download',
|
url: server.PREFIX + 'download',
|
||||||
},
|
},
|
||||||
})).toContainTextContent('### Downloads');
|
})).toHaveResponse({
|
||||||
|
downloads: expect.stringContaining(`- Downloaded file test.txt to`),
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -22,10 +22,13 @@ import { chromium } from 'playwright';
|
|||||||
import { test as baseTest, expect as baseExpect } from '@playwright/test';
|
import { test as baseTest, expect as baseExpect } from '@playwright/test';
|
||||||
import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js';
|
import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js';
|
||||||
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
|
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
|
||||||
|
import { ListRootsRequestSchema } from '@modelcontextprotocol/sdk/types.js';
|
||||||
import { TestServer } from './testserver/index.ts';
|
import { TestServer } from './testserver/index.ts';
|
||||||
|
|
||||||
import type { Config } from '../config';
|
import type { Config } from '../config';
|
||||||
import type { BrowserContext } from 'playwright';
|
import type { BrowserContext } from 'playwright';
|
||||||
|
import type { Transport } from '@modelcontextprotocol/sdk/shared/transport.js';
|
||||||
|
import type { Stream } from 'stream';
|
||||||
|
|
||||||
export type TestOptions = {
|
export type TestOptions = {
|
||||||
mcpBrowser: string | undefined;
|
mcpBrowser: string | undefined;
|
||||||
@@ -39,8 +42,12 @@ type CDPServer = {
|
|||||||
|
|
||||||
type TestFixtures = {
|
type TestFixtures = {
|
||||||
client: Client;
|
client: Client;
|
||||||
visionClient: Client;
|
startClient: (options?: {
|
||||||
startClient: (options?: { clientName?: string, args?: string[], config?: Config }) => Promise<{ client: Client, stderr: () => string }>;
|
clientName?: string,
|
||||||
|
args?: string[],
|
||||||
|
config?: Config,
|
||||||
|
roots?: { name: string, uri: string }[],
|
||||||
|
}) => Promise<{ client: Client, stderr: () => string }>;
|
||||||
wsEndpoint: string;
|
wsEndpoint: string;
|
||||||
cdpServer: CDPServer;
|
cdpServer: CDPServer;
|
||||||
server: TestServer;
|
server: TestServer;
|
||||||
@@ -59,20 +66,12 @@ export const test = baseTest.extend<TestFixtures & TestOptions, WorkerFixtures>(
|
|||||||
await use(client);
|
await use(client);
|
||||||
},
|
},
|
||||||
|
|
||||||
visionClient: async ({ startClient }, use) => {
|
|
||||||
const { client } = await startClient({ args: ['--vision'] });
|
|
||||||
await use(client);
|
|
||||||
},
|
|
||||||
|
|
||||||
startClient: async ({ mcpHeadless, mcpBrowser, mcpMode }, use, testInfo) => {
|
startClient: async ({ mcpHeadless, mcpBrowser, mcpMode }, use, testInfo) => {
|
||||||
const userDataDir = mcpMode !== 'docker' ? testInfo.outputPath('user-data-dir') : undefined;
|
|
||||||
const configDir = path.dirname(test.info().config.configFile!);
|
const configDir = path.dirname(test.info().config.configFile!);
|
||||||
let client: Client | undefined;
|
let client: Client | undefined;
|
||||||
|
|
||||||
await use(async options => {
|
await use(async options => {
|
||||||
const args: string[] = [];
|
const args: string[] = [];
|
||||||
if (userDataDir)
|
|
||||||
args.push('--user-data-dir', userDataDir);
|
|
||||||
if (process.env.CI && process.platform === 'linux')
|
if (process.env.CI && process.platform === 'linux')
|
||||||
args.push('--no-sandbox');
|
args.push('--no-sandbox');
|
||||||
if (mcpHeadless)
|
if (mcpHeadless)
|
||||||
@@ -87,17 +86,24 @@ export const test = baseTest.extend<TestFixtures & TestOptions, WorkerFixtures>(
|
|||||||
args.push(`--config=${path.relative(configDir, configFile)}`);
|
args.push(`--config=${path.relative(configDir, configFile)}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
client = new Client({ name: options?.clientName ?? 'test', version: '1.0.0' });
|
client = new Client({ name: options?.clientName ?? 'test', version: '1.0.0' }, options?.roots ? { capabilities: { roots: {} } } : undefined);
|
||||||
const transport = createTransport(args, mcpMode);
|
if (options?.roots) {
|
||||||
let stderr = '';
|
client.setRequestHandler(ListRootsRequestSchema, async request => {
|
||||||
transport.stderr?.on('data', data => {
|
return {
|
||||||
|
roots: options.roots,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const { transport, stderr } = await createTransport(args, mcpMode, testInfo.outputPath('ms-playwright'));
|
||||||
|
let stderrBuffer = '';
|
||||||
|
stderr?.on('data', data => {
|
||||||
if (process.env.PWMCP_DEBUG)
|
if (process.env.PWMCP_DEBUG)
|
||||||
process.stderr.write(data);
|
process.stderr.write(data);
|
||||||
stderr += data.toString();
|
stderrBuffer += data.toString();
|
||||||
});
|
});
|
||||||
await client.connect(transport);
|
await client.connect(transport);
|
||||||
await client.ping();
|
await client.ping();
|
||||||
return { client, stderr: () => stderr };
|
return { client, stderr: () => stderrBuffer };
|
||||||
});
|
});
|
||||||
|
|
||||||
await client?.close();
|
await client?.close();
|
||||||
@@ -138,7 +144,7 @@ export const test = baseTest.extend<TestFixtures & TestOptions, WorkerFixtures>(
|
|||||||
|
|
||||||
mcpMode: [undefined, { option: true }],
|
mcpMode: [undefined, { option: true }],
|
||||||
|
|
||||||
_workerServers: [async ({}, use, workerInfo) => {
|
_workerServers: [async ({ }, use, workerInfo) => {
|
||||||
const port = 8907 + workerInfo.workerIndex * 4;
|
const port = 8907 + workerInfo.workerIndex * 4;
|
||||||
const server = await TestServer.create(port);
|
const server = await TestServer.create(port);
|
||||||
|
|
||||||
@@ -164,71 +170,54 @@ export const test = baseTest.extend<TestFixtures & TestOptions, WorkerFixtures>(
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
function createTransport(args: string[], mcpMode: TestOptions['mcpMode']) {
|
async function createTransport(args: string[], mcpMode: TestOptions['mcpMode'], profilesDir: string): Promise<{
|
||||||
|
transport: Transport,
|
||||||
|
stderr: Stream | null,
|
||||||
|
}> {
|
||||||
// NOTE: Can be removed when we drop Node.js 18 support and changed to import.meta.filename.
|
// 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 __filename = url.fileURLToPath(import.meta.url);
|
||||||
if (mcpMode === 'docker') {
|
if (mcpMode === 'docker') {
|
||||||
const dockerArgs = ['run', '--rm', '-i', '--network=host', '-v', `${test.info().project.outputDir}:/app/test-results`];
|
const dockerArgs = ['run', '--rm', '-i', '--network=host', '-v', `${test.info().project.outputDir}:/app/test-results`];
|
||||||
return new StdioClientTransport({
|
const transport = new StdioClientTransport({
|
||||||
command: 'docker',
|
command: 'docker',
|
||||||
args: [...dockerArgs, 'playwright-mcp-dev:latest', ...args],
|
args: [...dockerArgs, 'playwright-mcp-dev:latest', ...args],
|
||||||
});
|
});
|
||||||
|
return {
|
||||||
|
transport,
|
||||||
|
stderr: transport.stderr,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
return new StdioClientTransport({
|
|
||||||
|
const transport = new StdioClientTransport({
|
||||||
command: 'node',
|
command: 'node',
|
||||||
args: [path.join(path.dirname(__filename), '../cli.js'), ...args],
|
args: [path.join(path.dirname(__filename), '../cli.js'), ...args],
|
||||||
cwd: path.join(path.dirname(__filename), '..'),
|
cwd: path.dirname(test.info().config.configFile!),
|
||||||
stderr: 'pipe',
|
stderr: 'pipe',
|
||||||
env: {
|
env: {
|
||||||
...process.env,
|
...process.env,
|
||||||
DEBUG: 'pw:mcp:test',
|
DEBUG: 'pw:mcp:test',
|
||||||
DEBUG_COLORS: '0',
|
DEBUG_COLORS: '0',
|
||||||
DEBUG_HIDE_DATE: '1',
|
DEBUG_HIDE_DATE: '1',
|
||||||
|
PWMCP_PROFILES_DIR_FOR_TEST: profilesDir,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
return {
|
||||||
|
transport,
|
||||||
|
stderr: transport.stderr!,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
type Response = Awaited<ReturnType<Client['callTool']>>;
|
type Response = Awaited<ReturnType<Client['callTool']>>;
|
||||||
|
|
||||||
export const expect = baseExpect.extend({
|
export const expect = baseExpect.extend({
|
||||||
toHaveTextContent(response: Response, content: string | RegExp) {
|
toHaveResponse(response: Response, object: any) {
|
||||||
|
const parsed = parseResponse(response);
|
||||||
const isNot = this.isNot;
|
const isNot = this.isNot;
|
||||||
try {
|
try {
|
||||||
const text = (response.content as any)[0].text;
|
if (isNot)
|
||||||
if (typeof content === 'string') {
|
expect(parsed).not.toEqual(expect.objectContaining(object));
|
||||||
if (isNot)
|
else
|
||||||
baseExpect(text.trim()).not.toBe(content.trim());
|
expect(parsed).toEqual(expect.objectContaining(object));
|
||||||
else
|
|
||||||
baseExpect(text.trim()).toBe(content.trim());
|
|
||||||
} else {
|
|
||||||
if (isNot)
|
|
||||||
baseExpect(text).not.toMatch(content);
|
|
||||||
else
|
|
||||||
baseExpect(text).toMatch(content);
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
return {
|
|
||||||
pass: isNot,
|
|
||||||
message: () => e.message,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
pass: !isNot,
|
|
||||||
message: () => ``,
|
|
||||||
};
|
|
||||||
},
|
|
||||||
|
|
||||||
toContainTextContent(response: Response, content: string | string[]) {
|
|
||||||
const isNot = this.isNot;
|
|
||||||
try {
|
|
||||||
content = Array.isArray(content) ? content : [content];
|
|
||||||
const texts = (response.content as any).map(c => c.text);
|
|
||||||
for (let i = 0; i < texts.length; i++) {
|
|
||||||
if (isNot)
|
|
||||||
expect(texts[i]).not.toContain(content[i]);
|
|
||||||
else
|
|
||||||
expect(texts[i]).toContain(content[i]);
|
|
||||||
}
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
return {
|
return {
|
||||||
pass: isNot,
|
pass: isNot,
|
||||||
@@ -245,3 +234,48 @@ export const expect = baseExpect.extend({
|
|||||||
export function formatOutput(output: string): string[] {
|
export function formatOutput(output: string): string[] {
|
||||||
return output.split('\n').map(line => line.replace(/^pw:mcp:test /, '').replace(/user data dir.*/, 'user data dir').trim()).filter(Boolean);
|
return output.split('\n').map(line => line.replace(/^pw:mcp:test /, '').replace(/user data dir.*/, 'user data dir').trim()).filter(Boolean);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function parseResponse(response: any) {
|
||||||
|
const text = response.content[0].text;
|
||||||
|
const sections = parseSections(text);
|
||||||
|
|
||||||
|
const result = sections.get('Result');
|
||||||
|
const code = sections.get('Ran Playwright code');
|
||||||
|
const tabs = sections.get('Open tabs');
|
||||||
|
const pageState = sections.get('Page state');
|
||||||
|
const consoleMessages = sections.get('New console messages');
|
||||||
|
const modalState = sections.get('Modal state');
|
||||||
|
const downloads = sections.get('Downloads');
|
||||||
|
const codeNoFrame = code?.replace(/^```js\n/, '').replace(/\n```$/, '');
|
||||||
|
const isError = response.isError;
|
||||||
|
const attachments = response.content.slice(1);
|
||||||
|
|
||||||
|
return {
|
||||||
|
result,
|
||||||
|
code: codeNoFrame,
|
||||||
|
tabs,
|
||||||
|
pageState,
|
||||||
|
consoleMessages,
|
||||||
|
modalState,
|
||||||
|
downloads,
|
||||||
|
isError,
|
||||||
|
attachments,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseSections(text: string): Map<string, string> {
|
||||||
|
const sections = new Map<string, string>();
|
||||||
|
const sectionHeaders = text.split(/^### /m).slice(1); // Remove empty first element
|
||||||
|
|
||||||
|
for (const section of sectionHeaders) {
|
||||||
|
const firstNewlineIndex = section.indexOf('\n');
|
||||||
|
if (firstNewlineIndex === -1)
|
||||||
|
continue;
|
||||||
|
|
||||||
|
const sectionName = section.substring(0, firstNewlineIndex);
|
||||||
|
const sectionContent = section.substring(firstNewlineIndex + 1).trim();
|
||||||
|
sections.set(sectionName, sectionContent);
|
||||||
|
}
|
||||||
|
|
||||||
|
return sections;
|
||||||
|
}
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ for (const mcpHeadless of [false, true]) {
|
|||||||
test.use({ mcpHeadless });
|
test.use({ mcpHeadless });
|
||||||
test.skip(process.platform === 'linux', 'Auto-detection wont let this test run on linux');
|
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.skip(({ mcpMode, mcpHeadless }) => mcpMode === 'docker' && !mcpHeadless, 'Headed mode is not supported in docker');
|
||||||
|
|
||||||
test('browser', async ({ client, server, mcpBrowser }) => {
|
test('browser', async ({ client, server, mcpBrowser }) => {
|
||||||
test.skip(!['chrome', 'msedge', 'chromium'].includes(mcpBrowser ?? ''), 'Only chrome is supported for this test');
|
test.skip(!['chrome', 'msedge', 'chromium'].includes(mcpBrowser ?? ''), 'Only chrome is supported for this test');
|
||||||
server.route('/', (req, res) => {
|
server.route('/', (req, res) => {
|
||||||
@@ -40,11 +41,9 @@ for (const mcpHeadless of [false, true]) {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(response).toContainTextContent(`Mozilla/5.0`);
|
expect(response).toHaveResponse({
|
||||||
if (mcpHeadless)
|
pageState: (mcpHeadless ? expect : expect.not).stringContaining(`HeadlessChrome`),
|
||||||
expect(response).toContainTextContent(`HeadlessChrome`);
|
});
|
||||||
else
|
|
||||||
expect(response).not.toContainTextContent(`HeadlessChrome`);
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
259
tests/http.spec.ts
Normal file
259
tests/http.spec.ts
Normal file
@@ -0,0 +1,259 @@
|
|||||||
|
/**
|
||||||
|
* Copyright (c) Microsoft Corporation.
|
||||||
|
*
|
||||||
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
* you may not use this file except in compliance with the License.
|
||||||
|
* You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing, software
|
||||||
|
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
* See the License for the specific language governing permissions and
|
||||||
|
* limitations under the License.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import fs from 'node:fs';
|
||||||
|
import url from 'node:url';
|
||||||
|
|
||||||
|
import { ChildProcess, spawn } from 'node:child_process';
|
||||||
|
import path from 'node:path';
|
||||||
|
import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js';
|
||||||
|
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
|
||||||
|
|
||||||
|
import { test as baseTest, expect } from './fixtures.js';
|
||||||
|
import type { Config } from '../config.d.ts';
|
||||||
|
|
||||||
|
// NOTE: Can be removed when we drop Node.js 18 support and changed to import.meta.filename.
|
||||||
|
const __filename = url.fileURLToPath(import.meta.url);
|
||||||
|
|
||||||
|
const test = baseTest.extend<{ serverEndpoint: (options?: { args?: string[], noPort?: boolean }) => Promise<{ url: URL, stderr: () => string }> }>({
|
||||||
|
serverEndpoint: async ({ mcpHeadless }, use, testInfo) => {
|
||||||
|
let cp: ChildProcess | undefined;
|
||||||
|
const userDataDir = testInfo.outputPath('user-data-dir');
|
||||||
|
await use(async (options?: { args?: string[], noPort?: boolean }) => {
|
||||||
|
if (cp)
|
||||||
|
throw new Error('Process already running');
|
||||||
|
|
||||||
|
cp = spawn('node', [
|
||||||
|
path.join(path.dirname(__filename), '../cli.js'),
|
||||||
|
...(options?.noPort ? [] : ['--port=0']),
|
||||||
|
'--user-data-dir=' + userDataDir,
|
||||||
|
...(mcpHeadless ? ['--headless'] : []),
|
||||||
|
...(options?.args || []),
|
||||||
|
], {
|
||||||
|
stdio: 'pipe',
|
||||||
|
env: {
|
||||||
|
...process.env,
|
||||||
|
DEBUG: 'pw:mcp:test',
|
||||||
|
DEBUG_COLORS: '0',
|
||||||
|
DEBUG_HIDE_DATE: '1',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
let stderr = '';
|
||||||
|
const url = await new Promise<string>(resolve => cp!.stderr?.on('data', data => {
|
||||||
|
stderr += data.toString();
|
||||||
|
const match = stderr.match(/Listening on (http:\/\/.*)/);
|
||||||
|
if (match)
|
||||||
|
resolve(match[1]);
|
||||||
|
}));
|
||||||
|
|
||||||
|
return { url: new URL(url), stderr: () => stderr };
|
||||||
|
});
|
||||||
|
cp?.kill('SIGTERM');
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
test('http transport', async ({ serverEndpoint }) => {
|
||||||
|
const { url } = await serverEndpoint();
|
||||||
|
const transport = new StreamableHTTPClientTransport(new URL('/mcp', url));
|
||||||
|
const client = new Client({ name: 'test', version: '1.0.0' });
|
||||||
|
await client.connect(transport);
|
||||||
|
await client.ping();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('http 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 StreamableHTTPClientTransport(new URL('/mcp', url));
|
||||||
|
const client = new Client({ name: 'test', version: '1.0.0' });
|
||||||
|
await client.connect(transport);
|
||||||
|
await client.ping();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('http transport browser lifecycle (isolated)', async ({ serverEndpoint, server }) => {
|
||||||
|
const { url, stderr } = await serverEndpoint({ args: ['--isolated'] });
|
||||||
|
|
||||||
|
const transport1 = new StreamableHTTPClientTransport(new URL('/mcp', 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 },
|
||||||
|
});
|
||||||
|
/**
|
||||||
|
* src/client/streamableHttp.ts
|
||||||
|
* Clients that no longer need a particular session
|
||||||
|
* (e.g., because the user is leaving the client application) SHOULD send an
|
||||||
|
* HTTP DELETE to the MCP endpoint with the Mcp-Session-Id header to explicitly
|
||||||
|
* terminate the session.
|
||||||
|
*/
|
||||||
|
await transport1.terminateSession();
|
||||||
|
await client1.close();
|
||||||
|
|
||||||
|
const transport2 = new StreamableHTTPClientTransport(new URL('/mcp', 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 transport2.terminateSession();
|
||||||
|
await client2.close();
|
||||||
|
|
||||||
|
await expect(async () => {
|
||||||
|
const lines = stderr().split('\n');
|
||||||
|
expect(lines.filter(line => line.match(/create http session/)).length).toBe(2);
|
||||||
|
expect(lines.filter(line => line.match(/delete http 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('http transport browser lifecycle (isolated, multiclient)', async ({ serverEndpoint, server }) => {
|
||||||
|
const { url, stderr } = await serverEndpoint({ args: ['--isolated'] });
|
||||||
|
|
||||||
|
const transport1 = new StreamableHTTPClientTransport(new URL('/mcp', 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 StreamableHTTPClientTransport(new URL('/mcp', 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 transport1.terminateSession();
|
||||||
|
await client1.close();
|
||||||
|
|
||||||
|
const transport3 = new StreamableHTTPClientTransport(new URL('/mcp', 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 transport2.terminateSession();
|
||||||
|
await client2.close();
|
||||||
|
await transport3.terminateSession();
|
||||||
|
await client3.close();
|
||||||
|
|
||||||
|
await expect(async () => {
|
||||||
|
const lines = stderr().split('\n');
|
||||||
|
expect(lines.filter(line => line.match(/create http session/)).length).toBe(3);
|
||||||
|
expect(lines.filter(line => line.match(/delete http 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('http transport browser lifecycle (persistent)', async ({ serverEndpoint, server }) => {
|
||||||
|
const { url, stderr } = await serverEndpoint();
|
||||||
|
|
||||||
|
const transport1 = new StreamableHTTPClientTransport(new URL('/mcp', 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 transport1.terminateSession();
|
||||||
|
await client1.close();
|
||||||
|
|
||||||
|
const transport2 = new StreamableHTTPClientTransport(new URL('/mcp', 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 transport2.terminateSession();
|
||||||
|
await client2.close();
|
||||||
|
|
||||||
|
await expect(async () => {
|
||||||
|
const lines = stderr().split('\n');
|
||||||
|
expect(lines.filter(line => line.match(/create http session/)).length).toBe(2);
|
||||||
|
expect(lines.filter(line => line.match(/delete http 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('http transport browser lifecycle (persistent, multiclient)', async ({ serverEndpoint, server }) => {
|
||||||
|
const { url } = await serverEndpoint();
|
||||||
|
|
||||||
|
const transport1 = new StreamableHTTPClientTransport(new URL('/mcp', 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 StreamableHTTPClientTransport(new URL('/mcp', 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('http transport (default)', async ({ serverEndpoint }) => {
|
||||||
|
const { url } = await serverEndpoint();
|
||||||
|
const transport = new StreamableHTTPClientTransport(url);
|
||||||
|
const client = new Client({ name: 'test', version: '1.0.0' });
|
||||||
|
await client.connect(transport);
|
||||||
|
await client.ping();
|
||||||
|
expect(transport.sessionId, 'has session support').toBeDefined();
|
||||||
|
});
|
||||||
@@ -22,17 +22,17 @@ test('stitched aria frames', async ({ client }) => {
|
|||||||
arguments: {
|
arguments: {
|
||||||
url: `data:text/html,<h1>Hello</h1><iframe src="data:text/html,<button>World</button><main><iframe src='data:text/html,<p>Nested</p>'></iframe></main>"></iframe><iframe src="data:text/html,<h1>Should be invisible</h1>" style="display: none;"></iframe>`,
|
url: `data:text/html,<h1>Hello</h1><iframe src="data:text/html,<button>World</button><main><iframe src='data:text/html,<p>Nested</p>'></iframe></main>"></iframe><iframe src="data:text/html,<h1>Should be invisible</h1>" style="display: none;"></iframe>`,
|
||||||
},
|
},
|
||||||
})).toContainTextContent(`
|
})).toHaveResponse({
|
||||||
\`\`\`yaml
|
pageState: expect.stringContaining(`- generic [active] [ref=e1]:
|
||||||
- generic [ref=e1]:
|
|
||||||
- heading "Hello" [level=1] [ref=e2]
|
- heading "Hello" [level=1] [ref=e2]
|
||||||
- iframe [ref=e3]:
|
- iframe [ref=e3]:
|
||||||
- generic [ref=f1e1]:
|
- generic [active] [ref=f1e1]:
|
||||||
- button "World" [ref=f1e2]
|
- button "World" [ref=f1e2]
|
||||||
- main [ref=f1e3]:
|
- main [ref=f1e3]:
|
||||||
- iframe [ref=f1e4]:
|
- iframe [ref=f1e4]:
|
||||||
- paragraph [ref=f2e2]: Nested
|
- paragraph [ref=f2e2]: Nested
|
||||||
\`\`\``);
|
\`\`\``),
|
||||||
|
});
|
||||||
|
|
||||||
expect(await client.callTool({
|
expect(await client.callTool({
|
||||||
name: 'browser_click',
|
name: 'browser_click',
|
||||||
@@ -40,5 +40,7 @@ test('stitched aria frames', async ({ client }) => {
|
|||||||
element: 'World',
|
element: 'World',
|
||||||
ref: 'f1e2',
|
ref: 'f1e2',
|
||||||
},
|
},
|
||||||
})).toContainTextContent(`// Click World`);
|
})).toHaveResponse({
|
||||||
|
code: `await page.locator('iframe').first().contentFrame().getByRole('button', { name: 'World' }).click();`,
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user