Compare commits
4 Commits
copilot/fi
...
copilot/fi
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
41ba5ba0eb | ||
|
|
d4667f865d | ||
|
|
ce81f556a5 | ||
|
|
fc127d5895 |
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 20
|
- name: Use Node.js 18
|
||||||
uses: actions/setup-node@v4
|
uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node-version: '20'
|
node-version: '18'
|
||||||
cache: 'npm'
|
cache: 'npm'
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: npm ci
|
run: npm ci
|
||||||
@@ -32,10 +32,11 @@ jobs:
|
|||||||
runs-on: ${{ matrix.os }}
|
runs-on: ${{ matrix.os }}
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
- name: Use Node.js 20
|
- name: Use Node.js 18
|
||||||
uses: actions/setup-node@v4
|
uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node-version: '20'
|
# https://github.com/microsoft/playwright-mcp/issues/344
|
||||||
|
node-version: '18.19'
|
||||||
cache: 'npm'
|
cache: 'npm'
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: npm ci
|
run: npm ci
|
||||||
@@ -54,10 +55,10 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
- name: Use Node.js 20
|
- name: Use Node.js 18
|
||||||
uses: actions/setup-node@v4
|
uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node-version: '20'
|
node-version: '18'
|
||||||
cache: 'npm'
|
cache: 'npm'
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: npm ci
|
run: npm ci
|
||||||
@@ -82,42 +83,3 @@ 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
|
|
||||||
|
|||||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -1,5 +1,4 @@
|
|||||||
lib/
|
lib/
|
||||||
dist/
|
|
||||||
node_modules/
|
node_modules/
|
||||||
test-results/
|
test-results/
|
||||||
playwright-report/
|
playwright-report/
|
||||||
|
|||||||
@@ -61,7 +61,7 @@ Follow the MCP install [guide](https://modelcontextprotocol.io/quickstart/user),
|
|||||||
|
|
||||||
#### Click the button to install:
|
#### Click the button to install:
|
||||||
|
|
||||||
[](cursor://anysphere.cursor-deeplink/mcp/install?name=Playwright&config=eyJjb21tYW5kIjoibnB4IEBwbGF5d3JpZ2h0L21jcEBsYXRlc3QifQ%3D%3D)
|
[](https://cursor.com/install-mcp?name=playwright&config=eyJjb21tYW5kIjoibnB4IEBwbGF5d3JpZ2h0L21jcEBsYXRlc3QifQ%3D%3D)
|
||||||
|
|
||||||
#### Or install manually:
|
#### Or install manually:
|
||||||
|
|
||||||
@@ -169,6 +169,8 @@ Playwright MCP server supports following arguments. They can be provided in the
|
|||||||
--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.
|
||||||
|
--permissions <permissions> comma-separated list of permissions to grant, for
|
||||||
|
example "clipboard-read,clipboard-write"
|
||||||
--port <port> port to listen on for SSE transport.
|
--port <port> port to listen on for SSE transport.
|
||||||
--proxy-bypass <bypass> comma-separated domains to bypass proxy, for
|
--proxy-bypass <bypass> comma-separated domains to bypass proxy, for
|
||||||
example ".com,chromium.org,.domain.com"
|
example ".com,chromium.org,.domain.com"
|
||||||
|
|||||||
@@ -18,12 +18,10 @@
|
|||||||
<head>
|
<head>
|
||||||
<title>Playwright MCP extension</title>
|
<title>Playwright MCP extension</title>
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
<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="stylesheet" href="src/ui/connect.css">
|
||||||
<link rel="icon" type="image/png" sizes="16x16" href="../../icons/icon-16.png">
|
|
||||||
<link rel="stylesheet" href="connect.css">
|
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
<script type="module" src="connect.tsx"></script>
|
<script type="module" src="src/ui/connect.tsx"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
312
extension/package-lock.json
generated
312
extension/package-lock.json
generated
@@ -16,8 +16,7 @@
|
|||||||
"react": "^18.2.0",
|
"react": "^18.2.0",
|
||||||
"react-dom": "^18.2.0",
|
"react-dom": "^18.2.0",
|
||||||
"typescript": "^5.8.2",
|
"typescript": "^5.8.2",
|
||||||
"vite": "^5.0.0",
|
"vite": "^5.0.0"
|
||||||
"vite-plugin-static-copy": "^3.1.1"
|
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=18"
|
"node": ">=18"
|
||||||
@@ -1091,46 +1090,6 @@
|
|||||||
"vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0"
|
"vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/anymatch": {
|
|
||||||
"version": "3.1.3",
|
|
||||||
"resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz",
|
|
||||||
"integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "ISC",
|
|
||||||
"dependencies": {
|
|
||||||
"normalize-path": "^3.0.0",
|
|
||||||
"picomatch": "^2.0.4"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">= 8"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/binary-extensions": {
|
|
||||||
"version": "2.3.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz",
|
|
||||||
"integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"engines": {
|
|
||||||
"node": ">=8"
|
|
||||||
},
|
|
||||||
"funding": {
|
|
||||||
"url": "https://github.com/sponsors/sindresorhus"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/braces": {
|
|
||||||
"version": "3.0.3",
|
|
||||||
"resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz",
|
|
||||||
"integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"fill-range": "^7.1.1"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=8"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/browserslist": {
|
"node_modules/browserslist": {
|
||||||
"version": "4.25.1",
|
"version": "4.25.1",
|
||||||
"resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.25.1.tgz",
|
"resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.25.1.tgz",
|
||||||
@@ -1183,31 +1142,6 @@
|
|||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"node_modules/chokidar": {
|
|
||||||
"version": "3.6.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz",
|
|
||||||
"integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"anymatch": "~3.1.2",
|
|
||||||
"braces": "~3.0.2",
|
|
||||||
"glob-parent": "~5.1.2",
|
|
||||||
"is-binary-path": "~2.1.0",
|
|
||||||
"is-glob": "~4.0.1",
|
|
||||||
"normalize-path": "~3.0.0",
|
|
||||||
"readdirp": "~3.6.0"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">= 8.10.0"
|
|
||||||
},
|
|
||||||
"funding": {
|
|
||||||
"url": "https://paulmillr.com/funding/"
|
|
||||||
},
|
|
||||||
"optionalDependencies": {
|
|
||||||
"fsevents": "~2.3.2"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/convert-source-map": {
|
"node_modules/convert-source-map": {
|
||||||
"version": "2.0.0",
|
"version": "2.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz",
|
||||||
@@ -1290,34 +1224,6 @@
|
|||||||
"node": ">=6"
|
"node": ">=6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/fill-range": {
|
|
||||||
"version": "7.1.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz",
|
|
||||||
"integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"to-regex-range": "^5.0.1"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=8"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/fs-extra": {
|
|
||||||
"version": "11.3.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-11.3.0.tgz",
|
|
||||||
"integrity": "sha512-Z4XaCL6dUDHfP/jT25jJKMmtxvuwbkrD1vNSMFlo9lNLY2c5FHYSQgHPRZUjAB26TpDEoW9HCOgplrdbaPV/ew==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"graceful-fs": "^4.2.0",
|
|
||||||
"jsonfile": "^6.0.1",
|
|
||||||
"universalify": "^2.0.0"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=14.14"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/fsevents": {
|
"node_modules/fsevents": {
|
||||||
"version": "2.3.3",
|
"version": "2.3.3",
|
||||||
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
|
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
|
||||||
@@ -1341,72 +1247,6 @@
|
|||||||
"node": ">=6.9.0"
|
"node": ">=6.9.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/glob-parent": {
|
|
||||||
"version": "5.1.2",
|
|
||||||
"resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz",
|
|
||||||
"integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "ISC",
|
|
||||||
"dependencies": {
|
|
||||||
"is-glob": "^4.0.1"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">= 6"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/graceful-fs": {
|
|
||||||
"version": "4.2.11",
|
|
||||||
"resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz",
|
|
||||||
"integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "ISC"
|
|
||||||
},
|
|
||||||
"node_modules/is-binary-path": {
|
|
||||||
"version": "2.1.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz",
|
|
||||||
"integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"binary-extensions": "^2.0.0"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=8"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/is-extglob": {
|
|
||||||
"version": "2.1.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz",
|
|
||||||
"integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"engines": {
|
|
||||||
"node": ">=0.10.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/is-glob": {
|
|
||||||
"version": "4.0.3",
|
|
||||||
"resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz",
|
|
||||||
"integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"is-extglob": "^2.1.1"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=0.10.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/is-number": {
|
|
||||||
"version": "7.0.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz",
|
|
||||||
"integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"engines": {
|
|
||||||
"node": ">=0.12.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/js-tokens": {
|
"node_modules/js-tokens": {
|
||||||
"version": "4.0.0",
|
"version": "4.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
|
||||||
@@ -1437,19 +1277,6 @@
|
|||||||
"node": ">=6"
|
"node": ">=6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/jsonfile": {
|
|
||||||
"version": "6.1.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.1.0.tgz",
|
|
||||||
"integrity": "sha512-5dgndWOriYSm5cnYaJNhalLNDKOqFwyDB/rr1E9ZsGciGvKPs8R2xYGCacuf3z6K1YKDz182fd+fY3cn3pMqXQ==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"universalify": "^2.0.0"
|
|
||||||
},
|
|
||||||
"optionalDependencies": {
|
|
||||||
"graceful-fs": "^4.1.6"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/loose-envify": {
|
"node_modules/loose-envify": {
|
||||||
"version": "1.4.0",
|
"version": "1.4.0",
|
||||||
"resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz",
|
"resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz",
|
||||||
@@ -1501,48 +1328,12 @@
|
|||||||
"integrity": "sha512-xxOWJsBKtzAq7DY0J+DTzuz58K8e7sJbdgwkbMWQe8UYB6ekmsQ45q0M/tJDsGaZmbC+l7n57UV8Hl5tHxO9uw==",
|
"integrity": "sha512-xxOWJsBKtzAq7DY0J+DTzuz58K8e7sJbdgwkbMWQe8UYB6ekmsQ45q0M/tJDsGaZmbC+l7n57UV8Hl5tHxO9uw==",
|
||||||
"dev": true
|
"dev": true
|
||||||
},
|
},
|
||||||
"node_modules/normalize-path": {
|
|
||||||
"version": "3.0.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz",
|
|
||||||
"integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"engines": {
|
|
||||||
"node": ">=0.10.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/p-map": {
|
|
||||||
"version": "7.0.3",
|
|
||||||
"resolved": "https://registry.npmjs.org/p-map/-/p-map-7.0.3.tgz",
|
|
||||||
"integrity": "sha512-VkndIv2fIB99swvQoA65bm+fsmt6UNdGeIB0oxBs+WhAhdh08QA04JXpI7rbB9r08/nkbysKoya9rtDERYOYMA==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"engines": {
|
|
||||||
"node": ">=18"
|
|
||||||
},
|
|
||||||
"funding": {
|
|
||||||
"url": "https://github.com/sponsors/sindresorhus"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/picocolors": {
|
"node_modules/picocolors": {
|
||||||
"version": "1.1.1",
|
"version": "1.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
|
||||||
"integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==",
|
"integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==",
|
||||||
"dev": true
|
"dev": true
|
||||||
},
|
},
|
||||||
"node_modules/picomatch": {
|
|
||||||
"version": "2.3.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz",
|
|
||||||
"integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"engines": {
|
|
||||||
"node": ">=8.6"
|
|
||||||
},
|
|
||||||
"funding": {
|
|
||||||
"url": "https://github.com/sponsors/jonschlinkert"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/postcss": {
|
"node_modules/postcss": {
|
||||||
"version": "8.5.6",
|
"version": "8.5.6",
|
||||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz",
|
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz",
|
||||||
@@ -1605,19 +1396,6 @@
|
|||||||
"node": ">=0.10.0"
|
"node": ">=0.10.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/readdirp": {
|
|
||||||
"version": "3.6.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz",
|
|
||||||
"integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"picomatch": "^2.2.1"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=8.10.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/rollup": {
|
"node_modules/rollup": {
|
||||||
"version": "4.46.1",
|
"version": "4.46.1",
|
||||||
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.46.1.tgz",
|
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.46.1.tgz",
|
||||||
@@ -1684,64 +1462,6 @@
|
|||||||
"node": ">=0.10.0"
|
"node": ">=0.10.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/tinyglobby": {
|
|
||||||
"version": "0.2.14",
|
|
||||||
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.14.tgz",
|
|
||||||
"integrity": "sha512-tX5e7OM1HnYr2+a2C/4V0htOcSQcoSTH9KgJnVvNm5zm/cyEWKJ7j7YutsH9CxMdtOkkLFy2AHrMci9IM8IPZQ==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"fdir": "^6.4.4",
|
|
||||||
"picomatch": "^4.0.2"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=12.0.0"
|
|
||||||
},
|
|
||||||
"funding": {
|
|
||||||
"url": "https://github.com/sponsors/SuperchupuDev"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/tinyglobby/node_modules/fdir": {
|
|
||||||
"version": "6.4.6",
|
|
||||||
"resolved": "https://registry.npmjs.org/fdir/-/fdir-6.4.6.tgz",
|
|
||||||
"integrity": "sha512-hiFoqpyZcfNm1yc4u8oWCf9A2c4D3QjCrks3zmoVKVxpQRzmPNar1hUJcBG2RQHvEVGDN+Jm81ZheVLAQMK6+w==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"peerDependencies": {
|
|
||||||
"picomatch": "^3 || ^4"
|
|
||||||
},
|
|
||||||
"peerDependenciesMeta": {
|
|
||||||
"picomatch": {
|
|
||||||
"optional": true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/tinyglobby/node_modules/picomatch": {
|
|
||||||
"version": "4.0.3",
|
|
||||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
|
|
||||||
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"engines": {
|
|
||||||
"node": ">=12"
|
|
||||||
},
|
|
||||||
"funding": {
|
|
||||||
"url": "https://github.com/sponsors/jonschlinkert"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/to-regex-range": {
|
|
||||||
"version": "5.0.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz",
|
|
||||||
"integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"is-number": "^7.0.0"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=8.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/typescript": {
|
"node_modules/typescript": {
|
||||||
"version": "5.8.3",
|
"version": "5.8.3",
|
||||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.8.3.tgz",
|
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.8.3.tgz",
|
||||||
@@ -1755,16 +1475,6 @@
|
|||||||
"node": ">=14.17"
|
"node": ">=14.17"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/universalify": {
|
|
||||||
"version": "2.0.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz",
|
|
||||||
"integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"engines": {
|
|
||||||
"node": ">= 10.0.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/update-browserslist-db": {
|
"node_modules/update-browserslist-db": {
|
||||||
"version": "1.1.3",
|
"version": "1.1.3",
|
||||||
"resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.1.3.tgz",
|
"resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.1.3.tgz",
|
||||||
@@ -1854,26 +1564,6 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/vite-plugin-static-copy": {
|
|
||||||
"version": "3.1.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/vite-plugin-static-copy/-/vite-plugin-static-copy-3.1.1.tgz",
|
|
||||||
"integrity": "sha512-oR53SkL5cX4KT1t18E/xU50vJDo0N8oaHza4EMk0Fm+2/u6nQivxavOfrDk3udWj+dizRizB/QnBvJOOQrTTAQ==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"chokidar": "^3.6.0",
|
|
||||||
"fs-extra": "^11.3.0",
|
|
||||||
"p-map": "^7.0.3",
|
|
||||||
"picocolors": "^1.1.1",
|
|
||||||
"tinyglobby": "^0.2.14"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": "^18.0.0 || >=20.0.0"
|
|
||||||
},
|
|
||||||
"peerDependencies": {
|
|
||||||
"vite": "^5.0.0 || ^6.0.0 || ^7.0.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/yallist": {
|
"node_modules/yallist": {
|
||||||
"version": "3.1.1",
|
"version": "3.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz",
|
||||||
|
|||||||
@@ -19,8 +19,7 @@
|
|||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "tsc --project . && tsc --project tsconfig.ui.json && vite build",
|
"build": "tsc --project . && tsc --project tsconfig.ui.json && vite build",
|
||||||
"watch": "tsc --watch --project . & tsc --watch --project tsconfig.ui.json & vite build --watch",
|
"watch": "tsc --watch --project . & tsc --watch --project tsconfig.ui.json & vite build --watch",
|
||||||
"test": "playwright test",
|
"clean": "rm -rf lib"
|
||||||
"clean": "rm -rf dist"
|
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/chrome": "^0.0.315",
|
"@types/chrome": "^0.0.315",
|
||||||
@@ -30,7 +29,6 @@
|
|||||||
"react": "^18.2.0",
|
"react": "^18.2.0",
|
||||||
"react-dom": "^18.2.0",
|
"react-dom": "^18.2.0",
|
||||||
"typescript": "^5.8.2",
|
"typescript": "^5.8.2",
|
||||||
"vite": "^5.0.0",
|
"vite": "^5.0.0"
|
||||||
"vite-plugin-static-copy": "^3.1.1"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,31 +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 { 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' } },
|
|
||||||
],
|
|
||||||
});
|
|
||||||
@@ -19,67 +19,42 @@ import { RelayConnection, debugLog } from './relayConnection.js';
|
|||||||
type PageMessage = {
|
type PageMessage = {
|
||||||
type: 'connectToMCPRelay';
|
type: 'connectToMCPRelay';
|
||||||
mcpRelayUrl: string;
|
mcpRelayUrl: string;
|
||||||
} | {
|
|
||||||
type: 'getTabs';
|
|
||||||
} | {
|
|
||||||
type: 'connectToTab';
|
|
||||||
tabId: number;
|
tabId: number;
|
||||||
windowId: number;
|
windowId: number;
|
||||||
mcpRelayUrl: string;
|
|
||||||
} | {
|
} | {
|
||||||
type: 'getConnectionStatus';
|
type: 'getTabs';
|
||||||
} | {
|
|
||||||
type: 'disconnect';
|
|
||||||
};
|
};
|
||||||
|
|
||||||
class TabShareExtension {
|
class TabShareExtension {
|
||||||
private _activeConnection: RelayConnection | undefined;
|
private _activeConnection: RelayConnection | undefined;
|
||||||
private _connectedTabId: number | null = null;
|
private _connectedTabId: number | null = null;
|
||||||
private _pendingTabSelection = new Map<number, { connection: RelayConnection, timerId?: number }>();
|
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
chrome.tabs.onRemoved.addListener(this._onTabRemoved.bind(this));
|
chrome.tabs.onRemoved.addListener(this._onTabRemoved.bind(this));
|
||||||
chrome.tabs.onUpdated.addListener(this._onTabUpdated.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.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
|
// 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) {
|
private _onMessage(message: PageMessage, sender: chrome.runtime.MessageSender, sendResponse: (response: any) => void) {
|
||||||
switch (message.type) {
|
switch (message.type) {
|
||||||
case 'connectToMCPRelay':
|
case 'connectToMCPRelay':
|
||||||
this._connectToRelay(sender.tab!.id!, message.mcpRelayUrl!).then(
|
this._connectTab(message.tabId, message.windowId, 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 }),
|
() => sendResponse({ success: true }),
|
||||||
(error: any) => sendResponse({ success: false, error: error.message }));
|
(error: any) => sendResponse({ success: false, error: error.message }));
|
||||||
return true; // Return true to indicate that the response will be sent asynchronously
|
return true; // Return true to indicate that the response will be sent asynchronously
|
||||||
case 'getConnectionStatus':
|
case 'getTabs':
|
||||||
sendResponse({
|
this._getTabs().then(
|
||||||
connectedTabId: this._connectedTabId
|
tabs => sendResponse({ success: true, tabs, currentTabId: sender.tab?.id }),
|
||||||
});
|
|
||||||
return false;
|
|
||||||
case 'disconnect':
|
|
||||||
this._disconnect().then(
|
|
||||||
() => sendResponse({ success: true }),
|
|
||||||
(error: any) => sendResponse({ success: false, error: error.message }));
|
(error: any) => sendResponse({ success: false, error: error.message }));
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
private async _connectToRelay(selectorTabId: number, mcpRelayUrl: string): Promise<void> {
|
private async _connectTab(tabId: number, windowId: number, mcpRelayUrl: string): Promise<void> {
|
||||||
try {
|
try {
|
||||||
debugLog(`Connecting to relay at ${mcpRelayUrl}`);
|
debugLog(`Connecting tab ${tabId} to bridge at ${mcpRelayUrl}`);
|
||||||
const socket = new WebSocket(mcpRelayUrl);
|
const socket = new WebSocket(mcpRelayUrl);
|
||||||
await new Promise<void>((resolve, reject) => {
|
await new Promise<void>((resolve, reject) => {
|
||||||
socket.onopen = () => resolve();
|
socket.onopen = () => resolve();
|
||||||
@@ -87,41 +62,17 @@ class TabShareExtension {
|
|||||||
setTimeout(() => reject(new Error('Connection timeout')), 5000);
|
setTimeout(() => reject(new Error('Connection timeout')), 5000);
|
||||||
});
|
});
|
||||||
|
|
||||||
const connection = new RelayConnection(socket);
|
const connection = new RelayConnection(socket, tabId);
|
||||||
connection.onclose = () => {
|
const connectionClosed = (m: string) => {
|
||||||
debugLog('Connection closed');
|
debugLog(m);
|
||||||
this._pendingTabSelection.delete(selectorTabId);
|
if (this._activeConnection === connection) {
|
||||||
// TODO: show error in the selector tab?
|
this._activeConnection = undefined;
|
||||||
};
|
void this._setConnectedTabId(null);
|
||||||
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);
|
|
||||||
};
|
};
|
||||||
|
socket.onclose = () => connectionClosed('WebSocket closed');
|
||||||
|
socket.onerror = error => connectionClosed(`WebSocket error: ${error}`);
|
||||||
|
this._activeConnection = connection;
|
||||||
|
|
||||||
await Promise.all([
|
await Promise.all([
|
||||||
this._setConnectedTabId(tabId),
|
this._setConnectedTabId(tabId),
|
||||||
@@ -140,29 +91,18 @@ class TabShareExtension {
|
|||||||
const oldTabId = this._connectedTabId;
|
const oldTabId = this._connectedTabId;
|
||||||
this._connectedTabId = tabId;
|
this._connectedTabId = tabId;
|
||||||
if (oldTabId && oldTabId !== tabId)
|
if (oldTabId && oldTabId !== tabId)
|
||||||
await this._updateBadge(oldTabId, { text: '' });
|
await this._updateBadge(oldTabId, { text: '', color: null });
|
||||||
if (tabId)
|
if (tabId)
|
||||||
await this._updateBadge(tabId, { text: '✓', color: '#4CAF50', title: 'Connected to MCP client' });
|
await this._updateBadge(tabId, { text: '●', color: '#4CAF50' });
|
||||||
}
|
}
|
||||||
|
|
||||||
private async _updateBadge(tabId: number, { text, color, title }: { text: string; color?: string, title?: string }): Promise<void> {
|
private async _updateBadge(tabId: number, { text, color }: { text: string; color: string | null }): Promise<void> {
|
||||||
try {
|
await chrome.action.setBadgeText({ tabId, text });
|
||||||
await chrome.action.setBadgeText({ tabId, text });
|
if (color)
|
||||||
await chrome.action.setTitle({ tabId, title: title || '' });
|
await chrome.action.setBadgeBackgroundColor({ tabId, color });
|
||||||
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> {
|
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)
|
if (this._connectedTabId !== tabId)
|
||||||
return;
|
return;
|
||||||
this._activeConnection?.close('Browser tab closed');
|
this._activeConnection?.close('Browser tab closed');
|
||||||
@@ -170,50 +110,15 @@ class TabShareExtension {
|
|||||||
this._connectedTabId = null;
|
this._connectedTabId = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
private _onTabActivated(activeInfo: chrome.tabs.TabActiveInfo) {
|
private async _onTabUpdated(tabId: number, changeInfo: chrome.tabs.TabChangeInfo, tab: chrome.tabs.Tab): Promise<void> {
|
||||||
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)
|
if (changeInfo.status === 'complete' && this._connectedTabId === tabId)
|
||||||
void this._setConnectedTabId(tabId);
|
await this._setConnectedTabId(tabId);
|
||||||
}
|
}
|
||||||
|
|
||||||
private async _getTabs(): Promise<chrome.tabs.Tab[]> {
|
private async _getTabs(): Promise<chrome.tabs.Tab[]> {
|
||||||
const tabs = await chrome.tabs.query({});
|
const tabs = await chrome.tabs.query({});
|
||||||
return tabs.filter(tab => tab.url && !['chrome:', 'edge:', 'devtools:'].some(scheme => tab.url!.startsWith(scheme)));
|
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();
|
new TabShareExtension();
|
||||||
|
|||||||
@@ -41,18 +41,11 @@ export class RelayConnection {
|
|||||||
private _ws: WebSocket;
|
private _ws: WebSocket;
|
||||||
private _eventListener: (source: chrome.debugger.DebuggerSession, method: string, params: any) => void;
|
private _eventListener: (source: chrome.debugger.DebuggerSession, method: string, params: any) => void;
|
||||||
private _detachListener: (source: chrome.debugger.Debuggee, reason: string) => 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, tabId: number) {
|
||||||
|
this._debuggee = { tabId };
|
||||||
constructor(ws: WebSocket) {
|
|
||||||
this._debuggee = { };
|
|
||||||
this._tabPromise = new Promise(resolve => this._tabPromiseResolve = resolve);
|
|
||||||
this._ws = ws;
|
this._ws = ws;
|
||||||
this._ws.onmessage = this._onMessage.bind(this);
|
this._ws.onmessage = this._onMessage.bind(this);
|
||||||
this._ws.onclose = () => this._onClose();
|
|
||||||
// Store listeners for cleanup
|
// Store listeners for cleanup
|
||||||
this._eventListener = this._onDebuggerEvent.bind(this);
|
this._eventListener = this._onDebuggerEvent.bind(this);
|
||||||
this._detachListener = this._onDebuggerDetach.bind(this);
|
this._detachListener = this._onDebuggerDetach.bind(this);
|
||||||
@@ -60,27 +53,10 @@ export class RelayConnection {
|
|||||||
chrome.debugger.onDetach.addListener(this._detachListener);
|
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 {
|
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.onEvent.removeListener(this._eventListener);
|
||||||
chrome.debugger.onDetach.removeListener(this._detachListener);
|
chrome.debugger.onDetach.removeListener(this._detachListener);
|
||||||
chrome.debugger.detach(this._debuggee).catch(() => {});
|
this._ws.close(1000, message);
|
||||||
this.onclose?.();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private _onDebuggerEvent(source: chrome.debugger.DebuggerSession, method: string, params: any): void {
|
private _onDebuggerEvent(source: chrome.debugger.DebuggerSession, method: string, params: any): void {
|
||||||
@@ -135,8 +111,9 @@ export class RelayConnection {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private async _handleCommand(message: ProtocolCommand): Promise<any> {
|
private async _handleCommand(message: ProtocolCommand): Promise<any> {
|
||||||
|
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 === 'attachToTab') {
|
if (message.method === 'attachToTab') {
|
||||||
await this._tabPromise;
|
|
||||||
debugLog('Attaching debugger to tab:', this._debuggee);
|
debugLog('Attaching debugger to tab:', this._debuggee);
|
||||||
await chrome.debugger.attach(this._debuggee, '1.3');
|
await chrome.debugger.attach(this._debuggee, '1.3');
|
||||||
const result: any = await chrome.debugger.sendCommand(this._debuggee, 'Target.getTargetInfo');
|
const result: any = await chrome.debugger.sendCommand(this._debuggee, 'Target.getTargetInfo');
|
||||||
@@ -144,8 +121,6 @@ export class RelayConnection {
|
|||||||
targetInfo: result?.targetInfo,
|
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') {
|
if (message.method === 'forwardCDPCommand') {
|
||||||
const { sessionId, method, params } = message.params;
|
const { sessionId, method, params } = message.params;
|
||||||
debugLog('CDP command:', method, params);
|
debugLog('CDP command:', method, params);
|
||||||
@@ -172,7 +147,6 @@ export class RelayConnection {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private _sendMessage(message: any): void {
|
private _sendMessage(message: any): void {
|
||||||
if (this._ws.readyState === WebSocket.OPEN)
|
this._ws.send(JSON.stringify(message));
|
||||||
this._ws.send(JSON.stringify(message));
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -25,9 +25,10 @@ body {
|
|||||||
background-color: #ffffff;
|
background-color: #ffffff;
|
||||||
color: #1f2328;
|
color: #1f2328;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
padding: 16px;
|
padding: 24px;
|
||||||
min-height: 100vh;
|
min-height: 100vh;
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
|
line-height: 1.5;
|
||||||
}
|
}
|
||||||
|
|
||||||
.content-wrapper {
|
.content-wrapper {
|
||||||
@@ -35,55 +36,50 @@ body {
|
|||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Status Banner */
|
.main-title {
|
||||||
.status-container {
|
font-size: 32px;
|
||||||
display: flex;
|
font-weight: 600;
|
||||||
align-items: center;
|
margin-bottom: 8px;
|
||||||
justify-content: space-between;
|
color: #1f2328;
|
||||||
margin-bottom: 16px;
|
|
||||||
padding-right: 12px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Status Banner */
|
||||||
.status-banner {
|
.status-banner {
|
||||||
padding: 12px;
|
padding: 16px;
|
||||||
|
margin-bottom: 24px;
|
||||||
|
border-radius: 6px;
|
||||||
|
border: 1px solid;
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 8px;
|
|
||||||
flex: 1;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.status-banner.connected {
|
.status-banner.connected {
|
||||||
color: #1f2328;
|
background-color: #dafbe1;
|
||||||
}
|
border-color: #1a7f37;
|
||||||
|
color: #0d5a23;
|
||||||
.status-banner.connected::before {
|
|
||||||
content: "\2705";
|
|
||||||
margin-right: 8px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.status-banner.error {
|
.status-banner.error {
|
||||||
color: #1f2328;
|
background-color: #ffebe9;
|
||||||
|
border-color: #da3633;
|
||||||
|
color: #a40e26;
|
||||||
}
|
}
|
||||||
|
|
||||||
.status-banner.error::before {
|
.status-banner.connecting {
|
||||||
content: "\274C";
|
background-color: #fff8c5;
|
||||||
margin-right: 8px;
|
border-color: #d1b500;
|
||||||
|
color: #7a5c00;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Buttons */
|
/* Buttons */
|
||||||
.button-container {
|
.button-container {
|
||||||
margin-bottom: 16px;
|
margin-bottom: 24px;
|
||||||
display: flex;
|
|
||||||
justify-content: flex-end;
|
|
||||||
padding-right: 12px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.button {
|
.button {
|
||||||
padding: 8px 16px;
|
padding: 8px 16px;
|
||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
border: none;
|
border: 1px solid;
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
@@ -92,63 +88,46 @@ body {
|
|||||||
justify-content: center;
|
justify-content: center;
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
margin-right: 8px;
|
margin-right: 8px;
|
||||||
min-width: 90px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.button.primary {
|
.button.primary {
|
||||||
background-color: #f8f9fa;
|
background-color: #2da44e;
|
||||||
color: #3c4043;
|
border-color: #2da44e;
|
||||||
border: 1px solid #dadce0;
|
color: #ffffff;
|
||||||
}
|
}
|
||||||
|
|
||||||
.button.primary:hover {
|
.button.primary:hover {
|
||||||
background-color: #f1f3f4;
|
background-color: #2c974b;
|
||||||
border-color: #dadce0;
|
|
||||||
box-shadow: 0 1px 2px 0 rgba(60,64,67,.1);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.button.default {
|
.button.default {
|
||||||
background-color: #f6f8fa;
|
background-color: #f6f8fa;
|
||||||
|
border-color: #d1d9e0;
|
||||||
color: #24292f;
|
color: #24292f;
|
||||||
}
|
}
|
||||||
|
|
||||||
.button.default:hover {
|
.button.default:hover {
|
||||||
background-color: #f3f4f6;
|
background-color: #f3f4f6;
|
||||||
}
|
border-color: #c7d2da;
|
||||||
|
|
||||||
.button.reject {
|
|
||||||
background-color: #da3633;
|
|
||||||
color: #ffffff;
|
|
||||||
border: 1px solid #da3633;
|
|
||||||
}
|
|
||||||
|
|
||||||
.button.reject:hover {
|
|
||||||
background-color: #c73836;
|
|
||||||
border-color: #c73836;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Tab selection */
|
/* Tab selection */
|
||||||
.tab-section-title {
|
.tab-section-title {
|
||||||
padding-left: 12px;
|
font-size: 20px;
|
||||||
font-size: 12px;
|
font-weight: 600;
|
||||||
font-weight: 400;
|
margin-bottom: 16px;
|
||||||
margin-bottom: 12px;
|
color: #1f2328;
|
||||||
color: #656d76;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.tab-item {
|
.tab-item {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
padding: 12px;
|
padding: 12px;
|
||||||
|
border: 1px solid #d1d9e0;
|
||||||
|
border-radius: 6px;
|
||||||
margin-bottom: 8px;
|
margin-bottom: 8px;
|
||||||
background-color: #ffffff;
|
background-color: #ffffff;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
border-radius: 6px;
|
|
||||||
transition: background-color 0.2s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tab-item:hover {
|
|
||||||
background-color: #f8f9fa;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.tab-item.selected {
|
.tab-item.selected {
|
||||||
|
|||||||
@@ -16,13 +16,20 @@
|
|||||||
|
|
||||||
import React, { useState, useEffect, useCallback } from 'react';
|
import React, { useState, useEffect, useCallback } from 'react';
|
||||||
import { createRoot } from 'react-dom/client';
|
import { createRoot } from 'react-dom/client';
|
||||||
import { Button, TabItem } from './tabItem.js';
|
|
||||||
import type { TabInfo } from './tabItem.js';
|
interface TabInfo {
|
||||||
|
id: number;
|
||||||
|
windowId: number;
|
||||||
|
title: string;
|
||||||
|
url: string;
|
||||||
|
favIconUrl?: string;
|
||||||
|
}
|
||||||
|
|
||||||
type StatusType = 'connected' | 'error' | 'connecting';
|
type StatusType = 'connected' | 'error' | 'connecting';
|
||||||
|
|
||||||
const ConnectApp: React.FC = () => {
|
const ConnectApp: React.FC = () => {
|
||||||
const [tabs, setTabs] = useState<TabInfo[]>([]);
|
const [tabs, setTabs] = useState<TabInfo[]>([]);
|
||||||
|
const [selectedTab, setSelectedTab] = useState<TabInfo | undefined>();
|
||||||
const [status, setStatus] = useState<{ type: StatusType; message: string } | null>(null);
|
const [status, setStatus] = useState<{ type: StatusType; message: string } | null>(null);
|
||||||
const [showButtons, setShowButtons] = useState(true);
|
const [showButtons, setShowButtons] = useState(true);
|
||||||
const [showTabList, setShowTabList] = useState(true);
|
const [showTabList, setShowTabList] = useState(true);
|
||||||
@@ -47,41 +54,42 @@ const ConnectApp: React.FC = () => {
|
|||||||
setClientInfo(info);
|
setClientInfo(info);
|
||||||
setStatus({
|
setStatus({
|
||||||
type: 'connecting',
|
type: 'connecting',
|
||||||
message: `🎭 Playwright MCP started from "${info}" is trying to connect. Do you want to continue?`
|
message: `MCP client "${info}" is trying to connect. Do you want to continue?`
|
||||||
});
|
});
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setStatus({ type: 'error', message: 'Failed to parse client version.' });
|
setStatus({ type: 'error', message: 'Failed to parse client version.' });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
void connectToMCPRelay(relayUrl);
|
|
||||||
void loadTabs();
|
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 loadTabs = useCallback(async () => {
|
||||||
const response = await chrome.runtime.sendMessage({ type: 'getTabs' });
|
const response = await chrome.runtime.sendMessage({ type: 'getTabs' });
|
||||||
if (response.success)
|
if (response.success) {
|
||||||
setTabs(response.tabs);
|
setTabs(response.tabs);
|
||||||
else
|
const currentTab = response.tabs.find((tab: TabInfo) => tab.id === response.currentTabId);
|
||||||
|
setSelectedTab(currentTab);
|
||||||
|
} else {
|
||||||
setStatus({ type: 'error', message: 'Failed to load tabs: ' + response.error });
|
setStatus({ type: 'error', message: 'Failed to load tabs: ' + response.error });
|
||||||
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const handleConnectToTab = useCallback(async (tab: TabInfo) => {
|
const handleContinue = useCallback(async () => {
|
||||||
setShowButtons(false);
|
setShowButtons(false);
|
||||||
setShowTabList(false);
|
setShowTabList(false);
|
||||||
|
|
||||||
|
if (!selectedTab) {
|
||||||
|
setStatus({ type: 'error', message: 'Tab not selected.' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await chrome.runtime.sendMessage({
|
const response = await chrome.runtime.sendMessage({
|
||||||
type: 'connectToTab',
|
type: 'connectToMCPRelay',
|
||||||
mcpRelayUrl,
|
mcpRelayUrl,
|
||||||
tabId: tab.id,
|
tabId: selectedTab.id,
|
||||||
windowId: tab.windowId,
|
windowId: selectedTab.windowId,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (response?.success) {
|
if (response?.success) {
|
||||||
@@ -98,7 +106,7 @@ const ConnectApp: React.FC = () => {
|
|||||||
message: `MCP client "${clientInfo}" failed to connect: ${e}`
|
message: `MCP client "${clientInfo}" failed to connect: ${e}`
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}, [clientInfo, mcpRelayUrl]);
|
}, [selectedTab, clientInfo, mcpRelayUrl]);
|
||||||
|
|
||||||
const handleReject = useCallback(() => {
|
const handleReject = useCallback(() => {
|
||||||
setShowButtons(false);
|
setShowButtons(false);
|
||||||
@@ -106,46 +114,39 @@ const ConnectApp: React.FC = () => {
|
|||||||
setStatus({ type: 'error', message: 'Connection rejected. This tab can be closed.' });
|
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 (
|
return (
|
||||||
<div className='app-container'>
|
<div className='app-container'>
|
||||||
<div className='content-wrapper'>
|
<div className='content-wrapper'>
|
||||||
{status && (
|
<h1 className='main-title'>
|
||||||
<div className='status-container'>
|
Playwright MCP Extension
|
||||||
<StatusBanner type={status.type} message={status.message} />
|
</h1>
|
||||||
{showButtons && (
|
|
||||||
<Button variant='reject' onClick={handleReject}>
|
{status && <StatusBanner type={status.type} message={status.message} />}
|
||||||
Reject
|
|
||||||
</Button>
|
{showButtons && (
|
||||||
)}
|
<div className='button-container'>
|
||||||
|
<Button variant='primary' onClick={handleContinue}>
|
||||||
|
Continue
|
||||||
|
</Button>
|
||||||
|
<Button variant='default' onClick={handleReject}>
|
||||||
|
Reject
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|
||||||
{showTabList && (
|
{showTabList && (
|
||||||
<div>
|
<div>
|
||||||
<div className='tab-section-title'>
|
<h2 className='tab-section-title'>
|
||||||
Select page to expose to MCP server:
|
Select page to expose to MCP server:
|
||||||
</div>
|
</h2>
|
||||||
<div>
|
<div>
|
||||||
{tabs.map(tab => (
|
{tabs.map(tab => (
|
||||||
<TabItem
|
<TabItem
|
||||||
key={tab.id}
|
key={tab.id}
|
||||||
tab={tab}
|
tab={tab}
|
||||||
button={
|
isSelected={selectedTab?.id === tab.id}
|
||||||
<Button variant='primary' onClick={() => handleConnectToTab(tab)}>
|
onSelect={() => setSelectedTab(tab)}
|
||||||
Connect
|
|
||||||
</Button>
|
|
||||||
}
|
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -160,6 +161,46 @@ const StatusBanner: React.FC<{ type: StatusType; message: string }> = ({ type, m
|
|||||||
return <div className={`status-banner ${type}`}>{message}</div>;
|
return <div className={`status-banner ${type}`}>{message}</div>;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const Button: React.FC<{ variant: 'primary' | 'default'; onClick: () => void; children: React.ReactNode }> = ({
|
||||||
|
variant,
|
||||||
|
onClick,
|
||||||
|
children
|
||||||
|
}) => {
|
||||||
|
return (
|
||||||
|
<button className={`button ${variant}`} onClick={onClick}>
|
||||||
|
{children}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const TabItem: React.FC<{ tab: TabInfo; isSelected: boolean; onSelect: () => void }> = ({
|
||||||
|
tab,
|
||||||
|
isSelected,
|
||||||
|
onSelect
|
||||||
|
}) => {
|
||||||
|
const className = `tab-item ${isSelected ? 'selected' : ''}`.trim();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={className} onClick={onSelect}>
|
||||||
|
<input
|
||||||
|
type='radio'
|
||||||
|
className='tab-radio'
|
||||||
|
checked={isSelected}
|
||||||
|
/>
|
||||||
|
<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>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
// Initialize the React app
|
// Initialize the React app
|
||||||
const container = document.getElementById('root');
|
const container = document.getElementById('root');
|
||||||
if (container) {
|
if (container) {
|
||||||
|
|||||||
@@ -1,13 +0,0 @@
|
|||||||
<!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>
|
|
||||||
@@ -1,110 +0,0 @@
|
|||||||
/**
|
|
||||||
* Copyright (c) Microsoft Corporation.
|
|
||||||
*
|
|
||||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
|
||||||
* you may not use this file except in compliance with the License.
|
|
||||||
* You may obtain a copy of the License at
|
|
||||||
*
|
|
||||||
* http://www.apache.org/licenses/LICENSE-2.0
|
|
||||||
*
|
|
||||||
* Unless required by applicable law or agreed to in writing, software
|
|
||||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
|
||||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
||||||
* See the License for the specific language governing permissions and
|
|
||||||
* limitations under the License.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import React, { 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 />);
|
|
||||||
}
|
|
||||||
@@ -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 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>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,152 +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 { 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);
|
|
||||||
});
|
|
||||||
@@ -6,9 +6,8 @@
|
|||||||
"strict": true,
|
"strict": true,
|
||||||
"module": "ESNext",
|
"module": "ESNext",
|
||||||
"rootDir": "src",
|
"rootDir": "src",
|
||||||
"outDir": "./dist/lib",
|
"outDir": "./lib",
|
||||||
"resolveJsonModule": true,
|
"resolveJsonModule": true,
|
||||||
"types": ["chrome"],
|
|
||||||
"jsx": "react-jsx",
|
"jsx": "react-jsx",
|
||||||
"jsxImportSource": "react"
|
"jsxImportSource": "react"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -17,37 +17,23 @@
|
|||||||
import { resolve } from 'path';
|
import { resolve } from 'path';
|
||||||
import { defineConfig } from 'vite';
|
import { defineConfig } from 'vite';
|
||||||
import react from '@vitejs/plugin-react';
|
import react from '@vitejs/plugin-react';
|
||||||
import { viteStaticCopy } from 'vite-plugin-static-copy';
|
|
||||||
|
|
||||||
// https://vitejs.dev/config/
|
// https://vitejs.dev/config/
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
plugins: [
|
plugins: [react()],
|
||||||
react(),
|
base: '/lib/ui/',
|
||||||
viteStaticCopy({
|
|
||||||
targets: [
|
|
||||||
{
|
|
||||||
src: '../../icons/*',
|
|
||||||
dest: 'icons'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
src: '../../manifest.json',
|
|
||||||
dest: '.'
|
|
||||||
}
|
|
||||||
]
|
|
||||||
})
|
|
||||||
],
|
|
||||||
root: resolve(__dirname, 'src/ui'),
|
|
||||||
build: {
|
build: {
|
||||||
outDir: resolve(__dirname, 'dist/'),
|
outDir: resolve(__dirname, 'lib/ui'),
|
||||||
emptyOutDir: false,
|
emptyOutDir: true,
|
||||||
minify: false,
|
minify: false,
|
||||||
rollupOptions: {
|
rollupOptions: {
|
||||||
input: ['src/ui/connect.html', 'src/ui/status.html'],
|
input: resolve(__dirname, 'connect.html'),
|
||||||
output: {
|
output: {
|
||||||
manualChunks: undefined,
|
manualChunks: undefined,
|
||||||
entryFileNames: 'lib/ui/[name].js',
|
inlineDynamicImports: true,
|
||||||
chunkFileNames: 'lib/ui/[name].js',
|
entryFileNames: '[name].js',
|
||||||
assetFileNames: 'lib/ui/[name].[ext]'
|
chunkFileNames: '[name].js',
|
||||||
|
assetFileNames: '[name].[ext]'
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
32
package-lock.json
generated
32
package-lock.json
generated
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "@playwright/mcp",
|
"name": "@playwright/mcp",
|
||||||
"version": "0.0.33",
|
"version": "0.0.32",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "@playwright/mcp",
|
"name": "@playwright/mcp",
|
||||||
"version": "0.0.33",
|
"version": "0.0.32",
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@modelcontextprotocol/sdk": "^1.16.0",
|
"@modelcontextprotocol/sdk": "^1.16.0",
|
||||||
@@ -14,8 +14,8 @@
|
|||||||
"debug": "^4.4.1",
|
"debug": "^4.4.1",
|
||||||
"dotenv": "^17.2.0",
|
"dotenv": "^17.2.0",
|
||||||
"mime": "^4.0.7",
|
"mime": "^4.0.7",
|
||||||
"playwright": "1.55.0-alpha-2025-08-07",
|
"playwright": "1.55.0-alpha-1753913825000",
|
||||||
"playwright-core": "1.55.0-alpha-2025-08-07",
|
"playwright-core": "1.55.0-alpha-1753913825000",
|
||||||
"ws": "^8.18.1",
|
"ws": "^8.18.1",
|
||||||
"zod": "^3.24.1",
|
"zod": "^3.24.1",
|
||||||
"zod-to-json-schema": "^3.24.4"
|
"zod-to-json-schema": "^3.24.4"
|
||||||
@@ -27,7 +27,7 @@
|
|||||||
"@anthropic-ai/sdk": "^0.57.0",
|
"@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.55.0-alpha-2025-08-07",
|
"@playwright/test": "1.55.0-alpha-1753913825000",
|
||||||
"@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",
|
||||||
@@ -703,13 +703,13 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@playwright/test": {
|
"node_modules/@playwright/test": {
|
||||||
"version": "1.55.0-alpha-2025-08-07",
|
"version": "1.55.0-alpha-1753913825000",
|
||||||
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.55.0-alpha-2025-08-07.tgz",
|
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.55.0-alpha-1753913825000.tgz",
|
||||||
"integrity": "sha512-N83L8JSSJ+E690HCbgzmXIcbRfM/rlh0uWZhbHbMp9q4qDPABSgvhm0HGiG345PV1ozoqcCI/mXLZPircsmPIA==",
|
"integrity": "sha512-YM5YHU6nTYNVzXlKvQvtEdXzpubLvdfEiTxwWvbqGHL/iDK2kBJd3L0psIG6yClU1wy01O756TkOOQSEpzOu7g==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"playwright": "1.55.0-alpha-2025-08-07"
|
"playwright": "1.55.0-alpha-1753913825000"
|
||||||
},
|
},
|
||||||
"bin": {
|
"bin": {
|
||||||
"playwright": "cli.js"
|
"playwright": "cli.js"
|
||||||
@@ -3745,12 +3745,12 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/playwright": {
|
"node_modules/playwright": {
|
||||||
"version": "1.55.0-alpha-2025-08-07",
|
"version": "1.55.0-alpha-1753913825000",
|
||||||
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.55.0-alpha-2025-08-07.tgz",
|
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.55.0-alpha-1753913825000.tgz",
|
||||||
"integrity": "sha512-rH8kdQOZzhjxC6FOL9zSEDwPl88ZqQq9QEvRDONWhzKwRQ/jOXlEZRxm8QRCBdrLqBMTGHx/YOaP7MIV//rtIA==",
|
"integrity": "sha512-IDyZzTu3tRNIjcx7/6ZmU7VmZPFGaW4jNsizwqbjSoeLFZPTLx2y693qeVVF/8KwEjuiSU3hVTQEzWvnx7cf2Q==",
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"playwright-core": "1.55.0-alpha-2025-08-07"
|
"playwright-core": "1.55.0-alpha-1753913825000"
|
||||||
},
|
},
|
||||||
"bin": {
|
"bin": {
|
||||||
"playwright": "cli.js"
|
"playwright": "cli.js"
|
||||||
@@ -3763,9 +3763,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/playwright-core": {
|
"node_modules/playwright-core": {
|
||||||
"version": "1.55.0-alpha-2025-08-07",
|
"version": "1.55.0-alpha-1753913825000",
|
||||||
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.55.0-alpha-2025-08-07.tgz",
|
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.55.0-alpha-1753913825000.tgz",
|
||||||
"integrity": "sha512-NUuC6R0/dLk1QKiYoJL8NUsQAC6Je0C2BpuIg5h4wcvBwJ5TFldslmik17Txg3TXBSqwgG76DAl4Q6UdHGn54Q==",
|
"integrity": "sha512-FH5pHzLseQxD8+d2wGlRa/I32AzJ+ZzcdDNM1aiSw5+gmq+aOo3PBqXHvhsh7tj0h4l2Qf6z9qf4mMiwijVthw==",
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"bin": {
|
"bin": {
|
||||||
"playwright-core": "cli.js"
|
"playwright-core": "cli.js"
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@playwright/mcp",
|
"name": "@playwright/mcp",
|
||||||
"version": "0.0.33",
|
"version": "0.0.32",
|
||||||
"description": "Playwright Tools for MCP",
|
"description": "Playwright Tools for MCP",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"repository": {
|
"repository": {
|
||||||
@@ -42,8 +42,8 @@
|
|||||||
"debug": "^4.4.1",
|
"debug": "^4.4.1",
|
||||||
"dotenv": "^17.2.0",
|
"dotenv": "^17.2.0",
|
||||||
"mime": "^4.0.7",
|
"mime": "^4.0.7",
|
||||||
"playwright": "1.55.0-alpha-2025-08-07",
|
"playwright": "1.55.0-alpha-1753913825000",
|
||||||
"playwright-core": "1.55.0-alpha-2025-08-07",
|
"playwright-core": "1.55.0-alpha-1753913825000",
|
||||||
"ws": "^8.18.1",
|
"ws": "^8.18.1",
|
||||||
"zod": "^3.24.1",
|
"zod": "^3.24.1",
|
||||||
"zod-to-json-schema": "^3.24.4"
|
"zod-to-json-schema": "^3.24.4"
|
||||||
@@ -52,7 +52,7 @@
|
|||||||
"@anthropic-ai/sdk": "^0.57.0",
|
"@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.55.0-alpha-2025-08-07",
|
"@playwright/test": "1.55.0-alpha-1753913825000",
|
||||||
"@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",
|
||||||
|
|||||||
@@ -138,7 +138,7 @@ class CdpContextFactory extends BaseContextFactory {
|
|||||||
}
|
}
|
||||||
|
|
||||||
protected override async _doCreateContext(browser: playwright.Browser): Promise<playwright.BrowserContext> {
|
protected override async _doCreateContext(browser: playwright.Browser): Promise<playwright.BrowserContext> {
|
||||||
return this.config.browser.isolated ? await browser.newContext() : browser.contexts()[0];
|
return this.config.browser.isolated ? await browser.newContext(this.config.browser.contextOptions) : browser.contexts()[0];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -156,7 +156,7 @@ class RemoteContextFactory 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();
|
return browser.newContext(this.config.browser.contextOptions);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -55,9 +55,7 @@ export class BrowserServerBackend implements ServerBackend {
|
|||||||
async initialize(server: mcpServer.Server): Promise<void> {
|
async initialize(server: mcpServer.Server): Promise<void> {
|
||||||
const capabilities = server.getClientCapabilities() as mcpServer.ClientCapabilities;
|
const capabilities = server.getClientCapabilities() as mcpServer.ClientCapabilities;
|
||||||
let rootPath: string | undefined;
|
let rootPath: string | undefined;
|
||||||
if (capabilities.roots && (
|
if (capabilities.roots) {
|
||||||
server.getClientVersion()?.name === 'Visual Studio Code' ||
|
|
||||||
server.getClientVersion()?.name === 'Visual Studio Code - Insiders')) {
|
|
||||||
const { roots } = await server.listRoots();
|
const { roots } = await server.listRoots();
|
||||||
const firstRootUri = roots[0]?.uri;
|
const firstRootUri = roots[0]?.uri;
|
||||||
const url = firstRootUri ? new URL(firstRootUri) : undefined;
|
const url = firstRootUri ? new URL(firstRootUri) : undefined;
|
||||||
|
|||||||
@@ -40,6 +40,7 @@ export type CLIOptions = {
|
|||||||
imageResponses?: 'allow' | 'omit';
|
imageResponses?: 'allow' | 'omit';
|
||||||
sandbox?: boolean;
|
sandbox?: boolean;
|
||||||
outputDir?: string;
|
outputDir?: string;
|
||||||
|
permissions?: string[];
|
||||||
port?: number;
|
port?: number;
|
||||||
proxyBypass?: string;
|
proxyBypass?: string;
|
||||||
proxyServer?: string;
|
proxyServer?: string;
|
||||||
@@ -170,6 +171,9 @@ export function configFromCLIOptions(cliOptions: CLIOptions): Config {
|
|||||||
if (cliOptions.blockServiceWorkers)
|
if (cliOptions.blockServiceWorkers)
|
||||||
contextOptions.serviceWorkers = 'block';
|
contextOptions.serviceWorkers = 'block';
|
||||||
|
|
||||||
|
if (cliOptions.permissions)
|
||||||
|
contextOptions.permissions = cliOptions.permissions;
|
||||||
|
|
||||||
const result: Config = {
|
const result: Config = {
|
||||||
browser: {
|
browser: {
|
||||||
browserName,
|
browserName,
|
||||||
@@ -216,6 +220,7 @@ function configFromEnv(): Config {
|
|||||||
options.imageResponses = 'omit';
|
options.imageResponses = 'omit';
|
||||||
options.sandbox = envToBoolean(process.env.PLAYWRIGHT_MCP_SANDBOX);
|
options.sandbox = envToBoolean(process.env.PLAYWRIGHT_MCP_SANDBOX);
|
||||||
options.outputDir = envToString(process.env.PLAYWRIGHT_MCP_OUTPUT_DIR);
|
options.outputDir = envToString(process.env.PLAYWRIGHT_MCP_OUTPUT_DIR);
|
||||||
|
options.permissions = commaSeparatedList(process.env.PLAYWRIGHT_MCP_PERMISSIONS);
|
||||||
options.port = envToNumber(process.env.PLAYWRIGHT_MCP_PORT);
|
options.port = envToNumber(process.env.PLAYWRIGHT_MCP_PORT);
|
||||||
options.proxyBypass = envToString(process.env.PLAYWRIGHT_MCP_PROXY_BYPASS);
|
options.proxyBypass = envToString(process.env.PLAYWRIGHT_MCP_PROXY_BYPASS);
|
||||||
options.proxyServer = envToString(process.env.PLAYWRIGHT_MCP_PROXY_SERVER);
|
options.proxyServer = envToString(process.env.PLAYWRIGHT_MCP_PROXY_SERVER);
|
||||||
|
|||||||
@@ -56,7 +56,6 @@ type CDPResponse = {
|
|||||||
export class CDPRelayServer {
|
export class CDPRelayServer {
|
||||||
private _wsHost: string;
|
private _wsHost: string;
|
||||||
private _browserChannel: string;
|
private _browserChannel: string;
|
||||||
private _userDataDir?: string;
|
|
||||||
private _cdpPath: string;
|
private _cdpPath: string;
|
||||||
private _extensionPath: string;
|
private _extensionPath: string;
|
||||||
private _wss: WebSocketServer;
|
private _wss: WebSocketServer;
|
||||||
@@ -70,10 +69,9 @@ export class CDPRelayServer {
|
|||||||
private _nextSessionId: number = 1;
|
private _nextSessionId: number = 1;
|
||||||
private _extensionConnectionPromise!: ManualPromise<void>;
|
private _extensionConnectionPromise!: ManualPromise<void>;
|
||||||
|
|
||||||
constructor(server: http.Server, browserChannel: string, userDataDir?: string) {
|
constructor(server: http.Server, browserChannel: string) {
|
||||||
this._wsHost = httpAddressToString(server.address()).replace(/^http/, 'ws');
|
this._wsHost = httpAddressToString(server.address()).replace(/^http/, 'ws');
|
||||||
this._browserChannel = browserChannel;
|
this._browserChannel = browserChannel;
|
||||||
this._userDataDir = userDataDir;
|
|
||||||
|
|
||||||
const uuid = crypto.randomUUID();
|
const uuid = crypto.randomUUID();
|
||||||
this._cdpPath = `/cdp/${uuid}`;
|
this._cdpPath = `/cdp/${uuid}`;
|
||||||
@@ -108,7 +106,7 @@ export class CDPRelayServer {
|
|||||||
private _connectBrowser(clientInfo: ClientInfo) {
|
private _connectBrowser(clientInfo: ClientInfo) {
|
||||||
const mcpRelayEndpoint = `${this._wsHost}${this._extensionPath}`;
|
const mcpRelayEndpoint = `${this._wsHost}${this._extensionPath}`;
|
||||||
// Need to specify "key" in the manifest.json to make the id stable when loading from file.
|
// 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');
|
const url = new URL('chrome-extension://jakfalbnbhgkpmoaakfflhflbfpkailf/lib/ui/connect.html');
|
||||||
url.searchParams.set('mcpRelayUrl', mcpRelayEndpoint);
|
url.searchParams.set('mcpRelayUrl', mcpRelayEndpoint);
|
||||||
url.searchParams.set('client', JSON.stringify(clientInfo));
|
url.searchParams.set('client', JSON.stringify(clientInfo));
|
||||||
const href = url.toString();
|
const href = url.toString();
|
||||||
@@ -119,12 +117,7 @@ export class CDPRelayServer {
|
|||||||
if (!executablePath)
|
if (!executablePath)
|
||||||
throw new Error(`"${this._browserChannel}" executable not found. Make sure it is installed at a standard location.`);
|
throw new Error(`"${this._browserChannel}" executable not found. Make sure it is installed at a standard location.`);
|
||||||
|
|
||||||
const args: string[] = [];
|
spawn(executablePath, [href], {
|
||||||
if (this._userDataDir)
|
|
||||||
args.push(`--user-data-dir=${this._userDataDir}`);
|
|
||||||
args.push(href);
|
|
||||||
|
|
||||||
spawn(executablePath, args, {
|
|
||||||
windowsHide: true,
|
windowsHide: true,
|
||||||
detached: true,
|
detached: true,
|
||||||
shell: false,
|
shell: false,
|
||||||
|
|||||||
@@ -28,39 +28,51 @@ export class ExtensionContextFactory implements BrowserContextFactory {
|
|||||||
description = 'Connect to a browser using the Playwright MCP extension';
|
description = 'Connect to a browser using the Playwright MCP extension';
|
||||||
|
|
||||||
private _browserChannel: string;
|
private _browserChannel: string;
|
||||||
private _userDataDir?: string;
|
private _relayPromise: Promise<CDPRelayServer> | undefined;
|
||||||
|
private _browserPromise: Promise<playwright.Browser> | undefined;
|
||||||
|
|
||||||
constructor(browserChannel: string, userDataDir: string | undefined) {
|
constructor(browserChannel: string) {
|
||||||
this._browserChannel = browserChannel;
|
this._browserChannel = browserChannel;
|
||||||
this._userDataDir = userDataDir;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async createContext(clientInfo: ClientInfo, abortSignal: AbortSignal): Promise<{ browserContext: playwright.BrowserContext, close: () => Promise<void> }> {
|
async createContext(clientInfo: ClientInfo, abortSignal: AbortSignal): Promise<{ browserContext: playwright.BrowserContext, close: () => Promise<void> }> {
|
||||||
const browser = await this._obtainBrowser(clientInfo, abortSignal);
|
// First call will establish the connection to the extension.
|
||||||
|
if (!this._browserPromise)
|
||||||
|
this._browserPromise = this._obtainBrowser(clientInfo, abortSignal);
|
||||||
|
const browser = await this._browserPromise;
|
||||||
return {
|
return {
|
||||||
browserContext: browser.contexts()[0],
|
browserContext: browser.contexts()[0],
|
||||||
close: async () => {
|
close: async () => {
|
||||||
debugLogger('close() called for browser context');
|
debugLogger('close() called for browser context');
|
||||||
await browser.close();
|
await browser.close();
|
||||||
|
this._browserPromise = undefined;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
private async _obtainBrowser(clientInfo: ClientInfo, abortSignal: AbortSignal): Promise<playwright.Browser> {
|
private async _obtainBrowser(clientInfo: ClientInfo, abortSignal: AbortSignal): Promise<playwright.Browser> {
|
||||||
const relay = await this._startRelay(abortSignal);
|
if (!this._relayPromise)
|
||||||
|
this._relayPromise = this._startRelay(abortSignal);
|
||||||
|
const relay = await this._relayPromise;
|
||||||
|
|
||||||
|
abortSignal.throwIfAborted();
|
||||||
await relay.ensureExtensionConnectionForMCPContext(clientInfo, abortSignal);
|
await relay.ensureExtensionConnectionForMCPContext(clientInfo, abortSignal);
|
||||||
return await playwright.chromium.connectOverCDP(relay.cdpEndpoint());
|
const browser = await playwright.chromium.connectOverCDP(relay.cdpEndpoint());
|
||||||
|
browser.on('disconnected', () => {
|
||||||
|
this._browserPromise = undefined;
|
||||||
|
debugLogger('Browser disconnected');
|
||||||
|
});
|
||||||
|
return browser;
|
||||||
}
|
}
|
||||||
|
|
||||||
private async _startRelay(abortSignal: AbortSignal) {
|
private async _startRelay(abortSignal: AbortSignal) {
|
||||||
const httpServer = await startHttpServer({});
|
const httpServer = await startHttpServer({});
|
||||||
if (abortSignal.aborted) {
|
const cdpRelayServer = new CDPRelayServer(httpServer, this._browserChannel);
|
||||||
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()}.`);
|
debugLogger(`CDP relay server started, extension endpoint: ${cdpRelayServer.extensionEndpoint()}.`);
|
||||||
|
if (abortSignal.aborted)
|
||||||
|
cdpRelayServer.stop();
|
||||||
|
else
|
||||||
|
abortSignal.addEventListener('abort', () => cdpRelayServer.stop());
|
||||||
return cdpRelayServer;
|
return cdpRelayServer;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,11 +21,11 @@ import * as mcpTransport from '../mcp/transport.js';
|
|||||||
import type { FullConfig } from '../config.js';
|
import type { FullConfig } from '../config.js';
|
||||||
|
|
||||||
export async function runWithExtension(config: FullConfig) {
|
export async function runWithExtension(config: FullConfig) {
|
||||||
const contextFactory = new ExtensionContextFactory(config.browser.launchOptions.channel || 'chrome', config.browser.userDataDir);
|
const contextFactory = new ExtensionContextFactory(config.browser.launchOptions.channel || 'chrome');
|
||||||
const serverBackendFactory = () => new BrowserServerBackend(config, [contextFactory]);
|
const serverBackendFactory = () => new BrowserServerBackend(config, [contextFactory]);
|
||||||
await mcpTransport.start(serverBackendFactory, config.server);
|
await mcpTransport.start(serverBackendFactory, config.server);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createExtensionContextFactory(config: FullConfig) {
|
export function createExtensionContextFactory(config: FullConfig) {
|
||||||
return new ExtensionContextFactory(config.browser.launchOptions.channel || 'chrome', config.browser.userDataDir);
|
return new ExtensionContextFactory(config.browser.launchOptions.channel || 'chrome');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -46,6 +46,7 @@ program
|
|||||||
.option('--image-responses <mode>', 'whether to send image responses to the client. Can be "allow" or "omit", Defaults to "allow".')
|
.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('--permissions <permissions>', 'comma-separated list of permissions to grant, for example "clipboard-read,clipboard-write"', commaSeparatedList)
|
||||||
.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"')
|
||||||
|
|||||||
@@ -75,15 +75,10 @@ const screenshot = defineTabTool({
|
|||||||
|
|
||||||
const buffer = locator ? await locator.screenshot(options) : await tab.page.screenshot(options);
|
const buffer = locator ? await locator.screenshot(options) : await tab.page.screenshot(options);
|
||||||
response.addResult(`Took the ${screenshotTarget} screenshot and saved it as ${fileName}`);
|
response.addResult(`Took the ${screenshotTarget} screenshot and saved it as ${fileName}`);
|
||||||
|
response.addImage({
|
||||||
// https://github.com/microsoft/playwright-mcp/issues/817
|
contentType: fileType === 'png' ? 'image/png' : 'image/jpeg',
|
||||||
// Never return large images to LLM, saving them to the file system is enough.
|
data: buffer
|
||||||
if (!params.fullPage) {
|
});
|
||||||
response.addImage({
|
|
||||||
contentType: fileType === 'png' ? 'image/png' : 'image/jpeg',
|
|
||||||
data: buffer
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -39,9 +39,7 @@ const wait = defineTool({
|
|||||||
const code: string[] = [];
|
const code: string[] = [];
|
||||||
|
|
||||||
if (params.time) {
|
if (params.time) {
|
||||||
const timeCode = `await new Promise(f => setTimeout(f, ${params.time!} * 1000));`;
|
code.push(`await new Promise(f => setTimeout(f, ${params.time!} * 1000));`);
|
||||||
code.push(timeCode);
|
|
||||||
response.addCode(timeCode);
|
|
||||||
await new Promise(f => setTimeout(f, Math.min(30000, params.time! * 1000)));
|
await new Promise(f => setTimeout(f, Math.min(30000, params.time! * 1000)));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -50,16 +48,12 @@ const wait = 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) {
|
||||||
const goneCode = `await page.getByText(${JSON.stringify(params.textGone)}).first().waitFor({ state: 'hidden' });`;
|
code.push(`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) {
|
||||||
const locatorCode = `await page.getByText(${JSON.stringify(params.text)}).first().waitFor({ state: 'visible' });`;
|
code.push(`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' });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -191,7 +191,7 @@ async function createTransport(args: string[], mcpMode: TestOptions['mcpMode'],
|
|||||||
const transport = 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.dirname(test.info().config.configFile!),
|
cwd: path.join(path.dirname(__filename), '..'),
|
||||||
stderr: 'pipe',
|
stderr: 'pipe',
|
||||||
env: {
|
env: {
|
||||||
...process.env,
|
...process.env,
|
||||||
|
|||||||
170
tests/permissions.spec.ts
Normal file
170
tests/permissions.spec.ts
Normal file
@@ -0,0 +1,170 @@
|
|||||||
|
/**
|
||||||
|
* 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('clipboard permissions support via CLI argument', async ({ startClient, server }) => {
|
||||||
|
server.setContent('/', `
|
||||||
|
<html>
|
||||||
|
<body>
|
||||||
|
<h1>Test Page</h1>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
`, 'text/html');
|
||||||
|
|
||||||
|
const { client } = await startClient({ args: ['--permissions', 'clipboard-read,clipboard-write'] });
|
||||||
|
|
||||||
|
// Navigate to server page
|
||||||
|
const navigateResponse = await client.callTool({
|
||||||
|
name: 'browser_navigate',
|
||||||
|
arguments: { url: server.PREFIX },
|
||||||
|
});
|
||||||
|
expect(navigateResponse.isError).toBeFalsy();
|
||||||
|
|
||||||
|
// Verify permissions are granted
|
||||||
|
const permissionsResponse = await client.callTool({
|
||||||
|
name: 'browser_evaluate',
|
||||||
|
arguments: {
|
||||||
|
function: '() => navigator.permissions.query({ name: "clipboard-write" }).then(result => result.state)'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
expect(permissionsResponse.isError).toBeFalsy();
|
||||||
|
expect(permissionsResponse).toHaveResponse({
|
||||||
|
result: '"granted"'
|
||||||
|
});
|
||||||
|
|
||||||
|
// Test clipboard write operation without user permission prompt
|
||||||
|
const writeResponse = await client.callTool({
|
||||||
|
name: 'browser_evaluate',
|
||||||
|
arguments: {
|
||||||
|
function: '() => navigator.clipboard.writeText("test clipboard content")'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
expect(writeResponse.isError).toBeFalsy();
|
||||||
|
|
||||||
|
// Test clipboard read operation without user permission prompt
|
||||||
|
const readResponse = await client.callTool({
|
||||||
|
name: 'browser_evaluate',
|
||||||
|
arguments: {
|
||||||
|
function: '() => navigator.clipboard.readText()'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
expect(readResponse.isError).toBeFalsy();
|
||||||
|
expect(readResponse).toHaveResponse({
|
||||||
|
result: '"test clipboard content"'
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('clipboard permissions support via config file contextOptions', async ({ startClient, server }) => {
|
||||||
|
server.setContent('/', `
|
||||||
|
<html>
|
||||||
|
<body>
|
||||||
|
<h1>Config Test Page</h1>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
`, 'text/html');
|
||||||
|
|
||||||
|
const config = {
|
||||||
|
browser: {
|
||||||
|
contextOptions: {
|
||||||
|
permissions: ['clipboard-read', 'clipboard-write']
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const { client } = await startClient({ config });
|
||||||
|
|
||||||
|
// Navigate to server page
|
||||||
|
const navigateResponse = await client.callTool({
|
||||||
|
name: 'browser_navigate',
|
||||||
|
arguments: { url: server.PREFIX },
|
||||||
|
});
|
||||||
|
expect(navigateResponse.isError).toBeFalsy();
|
||||||
|
|
||||||
|
// Verify permissions are granted via config
|
||||||
|
const permissionsResponse = await client.callTool({
|
||||||
|
name: 'browser_evaluate',
|
||||||
|
arguments: {
|
||||||
|
function: '() => navigator.permissions.query({ name: "clipboard-write" }).then(result => result.state)'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
expect(permissionsResponse.isError).toBeFalsy();
|
||||||
|
expect(permissionsResponse).toHaveResponse({
|
||||||
|
result: '"granted"'
|
||||||
|
});
|
||||||
|
|
||||||
|
// Test clipboard operations work with config file
|
||||||
|
const writeResponse = await client.callTool({
|
||||||
|
name: 'browser_evaluate',
|
||||||
|
arguments: {
|
||||||
|
function: '() => navigator.clipboard.writeText("config test content")'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
expect(writeResponse.isError).toBeFalsy();
|
||||||
|
|
||||||
|
const readResponse = await client.callTool({
|
||||||
|
name: 'browser_evaluate',
|
||||||
|
arguments: {
|
||||||
|
function: '() => navigator.clipboard.readText()'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
expect(readResponse.isError).toBeFalsy();
|
||||||
|
expect(readResponse).toHaveResponse({
|
||||||
|
result: '"config test content"'
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('multiple permissions can be granted', async ({ startClient, server }) => {
|
||||||
|
server.setContent('/', `
|
||||||
|
<html>
|
||||||
|
<body>
|
||||||
|
<h1>Multiple Permissions Test</h1>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
`, 'text/html');
|
||||||
|
|
||||||
|
const { client } = await startClient({ args: ['--permissions', 'clipboard-read,clipboard-write,geolocation'] });
|
||||||
|
|
||||||
|
// Navigate to server page
|
||||||
|
const navigateResponse = await client.callTool({
|
||||||
|
name: 'browser_navigate',
|
||||||
|
arguments: { url: server.PREFIX },
|
||||||
|
});
|
||||||
|
expect(navigateResponse.isError).toBeFalsy();
|
||||||
|
|
||||||
|
// Test that multiple permissions can be granted
|
||||||
|
const clipboardPermissionResponse = await client.callTool({
|
||||||
|
name: 'browser_evaluate',
|
||||||
|
arguments: {
|
||||||
|
function: '() => navigator.permissions.query({ name: "clipboard-write" }).then(result => result.state)'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
expect(clipboardPermissionResponse.isError).toBeFalsy();
|
||||||
|
expect(clipboardPermissionResponse).toHaveResponse({
|
||||||
|
result: '"granted"'
|
||||||
|
});
|
||||||
|
|
||||||
|
const geolocationPermissionResponse = await client.callTool({
|
||||||
|
name: 'browser_evaluate',
|
||||||
|
arguments: {
|
||||||
|
function: '() => navigator.permissions.query({ name: "geolocation" }).then(result => result.state)'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
expect(geolocationPermissionResponse.isError).toBeFalsy();
|
||||||
|
expect(geolocationPermissionResponse).toHaveResponse({
|
||||||
|
result: '"granted"'
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -25,7 +25,6 @@ const p = process.platform === 'win32' ? 'c:\\non\\existent\\folder' : '/non/exi
|
|||||||
|
|
||||||
test('should use separate user data by root path', async ({ startClient, server }, testInfo) => {
|
test('should use separate user data by root path', async ({ startClient, server }, testInfo) => {
|
||||||
const { client } = await startClient({
|
const { client } = await startClient({
|
||||||
clientName: 'Visual Studio Code', // Simulate VS Code client, roots only work with it
|
|
||||||
roots: [
|
roots: [
|
||||||
{
|
{
|
||||||
name: 'test',
|
name: 'test',
|
||||||
@@ -49,7 +48,6 @@ test('check that trace is saved in workspace', async ({ startClient, server, mcp
|
|||||||
const rootPath = testInfo.outputPath('workspace');
|
const rootPath = testInfo.outputPath('workspace');
|
||||||
const { client } = await startClient({
|
const { client } = await startClient({
|
||||||
args: ['--save-trace'],
|
args: ['--save-trace'],
|
||||||
clientName: 'Visual Studio Code - Insiders', // Simulate VS Code client, roots only work with it
|
|
||||||
roots: [
|
roots: [
|
||||||
{
|
{
|
||||||
name: 'workspace',
|
name: 'workspace',
|
||||||
|
|||||||
@@ -264,7 +264,12 @@ test('browser_take_screenshot (fullPage: true)', async ({ startClient, server },
|
|||||||
{
|
{
|
||||||
text: expect.stringContaining('fullPage: true'),
|
text: expect.stringContaining('fullPage: true'),
|
||||||
type: 'text',
|
type: 'text',
|
||||||
}
|
},
|
||||||
|
{
|
||||||
|
data: expect.any(String),
|
||||||
|
mimeType: 'image/png',
|
||||||
|
type: 'image',
|
||||||
|
},
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -48,7 +48,6 @@ test('browser_wait_for(text)', async ({ client, server }) => {
|
|||||||
name: 'browser_wait_for',
|
name: 'browser_wait_for',
|
||||||
arguments: { text: 'Text to appear' },
|
arguments: { text: 'Text to appear' },
|
||||||
})).toHaveResponse({
|
})).toHaveResponse({
|
||||||
code: `await page.getByText("Text to appear").first().waitFor({ state: 'visible' });`,
|
|
||||||
pageState: expect.stringContaining(`- generic [ref=e3]: Text to appear`),
|
pageState: expect.stringContaining(`- generic [ref=e3]: Text to appear`),
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -85,23 +84,6 @@ test('browser_wait_for(textGone)', async ({ client, server }) => {
|
|||||||
name: 'browser_wait_for',
|
name: 'browser_wait_for',
|
||||||
arguments: { textGone: 'Text to disappear' },
|
arguments: { textGone: 'Text to disappear' },
|
||||||
})).toHaveResponse({
|
})).toHaveResponse({
|
||||||
code: `await page.getByText("Text to disappear").first().waitFor({ state: 'hidden' });`,
|
|
||||||
pageState: expect.stringContaining(`- generic [ref=e3]: Text to appear`),
|
pageState: expect.stringContaining(`- generic [ref=e3]: Text to appear`),
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
test('browser_wait_for(time)', async ({ client, server }) => {
|
|
||||||
server.setContent('/', `<body><div>Hello World</div></body>`, 'text/html');
|
|
||||||
|
|
||||||
await client.callTool({
|
|
||||||
name: 'browser_navigate',
|
|
||||||
arguments: { url: server.PREFIX },
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(await client.callTool({
|
|
||||||
name: 'browser_wait_for',
|
|
||||||
arguments: { time: 1 },
|
|
||||||
})).toHaveResponse({
|
|
||||||
code: `await new Promise(f => setTimeout(f, 1 * 1000));`,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|||||||
Reference in New Issue
Block a user