Compare commits
19 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fea3f26e85 | ||
|
|
dd5b41f1d8 | ||
|
|
05dc5d915b | ||
|
|
65a229c79f | ||
|
|
84664d4b09 | ||
|
|
445170a76b | ||
|
|
c28b480b51 | ||
|
|
65716b60dd | ||
|
|
75f74a54bc | ||
|
|
ef41c626ef | ||
|
|
95ca08fdb7 | ||
|
|
053c2f3d32 | ||
|
|
57b3c14276 | ||
|
|
85c85bd2fb | ||
|
|
09ba7989c3 | ||
|
|
a115c31953 | ||
|
|
b5be37e5e7 | ||
|
|
c2255246a3 | ||
|
|
950d0d1d34 |
48
.github/workflows/ci.yml
vendored
48
.github/workflows/ci.yml
vendored
@@ -30,32 +30,56 @@ jobs:
|
|||||||
matrix:
|
matrix:
|
||||||
os: [ubuntu-latest, macos-latest, windows-latest]
|
os: [ubuntu-latest, macos-latest, windows-latest]
|
||||||
runs-on: ${{ matrix.os }}
|
runs-on: ${{ matrix.os }}
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
- name: Use Node.js 18
|
- name: Use Node.js 18
|
||||||
uses: actions/setup-node@v4
|
uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
# https://github.com/microsoft/playwright-mcp/issues/344
|
# https://github.com/microsoft/playwright-mcp/issues/344
|
||||||
node-version: '18.19'
|
node-version: '18.19'
|
||||||
cache: 'npm'
|
cache: 'npm'
|
||||||
|
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: npm ci
|
run: npm ci
|
||||||
|
|
||||||
- name: Playwright install
|
- name: Playwright install
|
||||||
run: npx playwright install --with-deps
|
run: npx playwright install --with-deps
|
||||||
|
|
||||||
- name: Install MS Edge
|
- name: Install MS Edge
|
||||||
if: ${{ matrix.os == 'macos-latest' }} # MS Edge is not preinstalled on macOS runners.
|
# MS Edge is not preinstalled on macOS runners.
|
||||||
|
if: ${{ matrix.os == 'macos-latest' }}
|
||||||
run: npx playwright install msedge
|
run: npx playwright install msedge
|
||||||
|
|
||||||
- name: Build
|
- name: Build
|
||||||
run: npm run build
|
run: npm run build
|
||||||
|
|
||||||
- name: Install Playwright browsers
|
|
||||||
run: npx playwright install --with-deps
|
|
||||||
|
|
||||||
- name: Run tests
|
- name: Run tests
|
||||||
run: npm test -- --forbid-only
|
run: npm test
|
||||||
|
|
||||||
|
test_docker:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- name: Use Node.js 18
|
||||||
|
uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: '18'
|
||||||
|
cache: 'npm'
|
||||||
|
- name: Install dependencies
|
||||||
|
run: npm ci
|
||||||
|
- name: Playwright install
|
||||||
|
run: npx playwright install --with-deps chromium
|
||||||
|
- name: Build
|
||||||
|
run: npm run build
|
||||||
|
- name: Set up Docker Buildx
|
||||||
|
uses: docker/setup-buildx-action@v3
|
||||||
|
- name: Build and push
|
||||||
|
uses: docker/build-push-action@v6
|
||||||
|
with:
|
||||||
|
tags: playwright-mcp-dev:latest
|
||||||
|
cache-from: type=gha
|
||||||
|
cache-to: type=gha,mode=max
|
||||||
|
load: true
|
||||||
|
- name: Run tests
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
# Used for the Docker tests to share the test-results folder with the container.
|
||||||
|
umask 0000
|
||||||
|
npm run test -- --project=chromium-docker
|
||||||
|
env:
|
||||||
|
MCP_IN_DOCKER: 1
|
||||||
|
|||||||
2
.github/workflows/publish.yml
vendored
2
.github/workflows/publish.yml
vendored
@@ -51,5 +51,5 @@ jobs:
|
|||||||
platforms: linux/amd64,linux/arm64
|
platforms: linux/amd64,linux/arm64
|
||||||
push: true
|
push: true
|
||||||
tags: |
|
tags: |
|
||||||
playwright.azurecr.io/public/playwright/mcp:${{ github.ref_name }}
|
playwright.azurecr.io/public/playwright/mcp:${{ github.event.release.tag_name }}
|
||||||
playwright.azurecr.io/public/playwright/mcp:latest
|
playwright.azurecr.io/public/playwright/mcp:latest
|
||||||
|
|||||||
22
README.md
22
README.md
@@ -207,7 +207,7 @@ And then in MCP client config, set the `url` to the SSE endpoint:
|
|||||||
"mcpServers": {
|
"mcpServers": {
|
||||||
"playwright": {
|
"playwright": {
|
||||||
"command": "docker",
|
"command": "docker",
|
||||||
"args": ["run", "-i", "--rm", "--init", "mcp/playwright"]
|
"args": ["run", "-i", "--rm", "--init", "--pull=always", "mcr.microsoft.com/playwright/mcp"]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -216,7 +216,7 @@ And then in MCP client config, set the `url` to the SSE endpoint:
|
|||||||
You can build the Docker image yourself.
|
You can build the Docker image yourself.
|
||||||
|
|
||||||
```
|
```
|
||||||
docker build -t mcp/playwright .
|
docker build -t mcr.microsoft.com/playwright/mcp .
|
||||||
```
|
```
|
||||||
|
|
||||||
### Programmatic usage
|
### Programmatic usage
|
||||||
@@ -224,14 +224,14 @@ docker build -t mcp/playwright .
|
|||||||
```js
|
```js
|
||||||
import http from 'http';
|
import http from 'http';
|
||||||
|
|
||||||
import { createServer } from '@playwright/mcp';
|
import { createConnection } from '@playwright/mcp';
|
||||||
import { SSEServerTransport } from '@modelcontextprotocol/sdk/server/sse.js';
|
import { SSEServerTransport } from '@modelcontextprotocol/sdk/server/sse.js';
|
||||||
|
|
||||||
http.createServer(async (req, res) => {
|
http.createServer(async (req, res) => {
|
||||||
// ...
|
// ...
|
||||||
|
|
||||||
// Creates a headless Playwright MCP server with SSE transport
|
// Creates a headless Playwright MCP server with SSE transport
|
||||||
const connection = await createConnection({ headless: true });
|
const connection = await createConnection({ browser: { launchOptions: { headless: true } } });
|
||||||
const transport = new SSEServerTransport('/messages', res);
|
const transport = new SSEServerTransport('/messages', res);
|
||||||
await connection.connect(transport);
|
await connection.connect(transport);
|
||||||
|
|
||||||
@@ -341,6 +341,7 @@ X Y coordinate space, based on the provided screenshot.
|
|||||||
- Description: Take a screenshot of the current page. You can't perform actions based on the screenshot, use browser_snapshot for actions.
|
- Description: Take a screenshot of the current page. You can't perform actions based on the screenshot, use browser_snapshot for actions.
|
||||||
- Parameters:
|
- Parameters:
|
||||||
- `raw` (boolean, optional): Whether to return without compression (in PNG format). Default is false, which returns a JPEG image.
|
- `raw` (boolean, optional): Whether to return without compression (in PNG format). Default is false, which returns a JPEG image.
|
||||||
|
- `filename` (string, optional): File name to save the screenshot to. Defaults to `page-{timestamp}.{png|jpeg}` if not specified.
|
||||||
- `element` (string, optional): Human-readable element description used to obtain permission to screenshot the element. If not provided, the screenshot will be taken of viewport. If element is provided, ref must be provided too.
|
- `element` (string, optional): Human-readable element description used to obtain permission to screenshot the element. If not provided, the screenshot will be taken of viewport. If element is provided, ref must be provided too.
|
||||||
- `ref` (string, optional): Exact target element reference from the page snapshot. If not provided, the screenshot will be taken of viewport. If ref is provided, element must be provided too.
|
- `ref` (string, optional): Exact target element reference from the page snapshot. If not provided, the screenshot will be taken of viewport. If ref is provided, element must be provided too.
|
||||||
- Read-only: **true**
|
- Read-only: **true**
|
||||||
@@ -501,7 +502,8 @@ X Y coordinate space, based on the provided screenshot.
|
|||||||
- **browser_pdf_save**
|
- **browser_pdf_save**
|
||||||
- Title: Save as PDF
|
- Title: Save as PDF
|
||||||
- Description: Save page as PDF
|
- Description: Save page as PDF
|
||||||
- Parameters: None
|
- Parameters:
|
||||||
|
- `filename` (string, optional): File name to save the pdf to. Defaults to `page-{timestamp}.pdf` if not specified.
|
||||||
- Read-only: **true**
|
- Read-only: **true**
|
||||||
|
|
||||||
### Utilities
|
### Utilities
|
||||||
@@ -516,11 +518,13 @@ X Y coordinate space, based on the provided screenshot.
|
|||||||
|
|
||||||
<!-- NOTE: This has been generated via update-readme.js -->
|
<!-- NOTE: This has been generated via update-readme.js -->
|
||||||
|
|
||||||
- **browser_wait**
|
- **browser_wait_for**
|
||||||
- Title: Wait
|
- Title: Wait for
|
||||||
- Description: Wait for a specified time in seconds
|
- Description: Wait for text to appear or disappear or a specified time to pass
|
||||||
- Parameters:
|
- Parameters:
|
||||||
- `time` (number): The time to wait in seconds
|
- `time` (number, optional): The time to wait in seconds
|
||||||
|
- `text` (string, optional): The text to wait for
|
||||||
|
- `textGone` (string, optional): The text to wait for to disappear
|
||||||
- Read-only: **true**
|
- Read-only: **true**
|
||||||
|
|
||||||
<!-- NOTE: This has been generated via update-readme.js -->
|
<!-- NOTE: This has been generated via update-readme.js -->
|
||||||
|
|||||||
2
config.d.ts
vendored
2
config.d.ts
vendored
@@ -40,7 +40,7 @@ export type Config = {
|
|||||||
*
|
*
|
||||||
* This is useful for settings options like `channel`, `headless`, `executablePath`, etc.
|
* This is useful for settings options like `channel`, `headless`, `executablePath`, etc.
|
||||||
*/
|
*/
|
||||||
launchOptions?: playwright.BrowserLaunchOptions;
|
launchOptions?: playwright.LaunchOptions;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Context options for the browser context.
|
* Context options for the browser context.
|
||||||
|
|||||||
2
index.js
2
index.js
@@ -16,4 +16,4 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { createConnection } from './lib/index';
|
import { createConnection } from './lib/index';
|
||||||
export default { createConnection };
|
export { createConnection };
|
||||||
|
|||||||
43
package-lock.json
generated
43
package-lock.json
generated
@@ -1,18 +1,17 @@
|
|||||||
{
|
{
|
||||||
"name": "@playwright/mcp",
|
"name": "@playwright/mcp",
|
||||||
"version": "0.0.21",
|
"version": "0.0.24",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "@playwright/mcp",
|
"name": "@playwright/mcp",
|
||||||
"version": "0.0.21",
|
"version": "0.0.24",
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@modelcontextprotocol/sdk": "^1.11.0",
|
"@modelcontextprotocol/sdk": "^1.11.0",
|
||||||
"commander": "^13.1.0",
|
"commander": "^13.1.0",
|
||||||
"playwright": "1.53.0-alpha-1746218818000",
|
"playwright": "1.53.0-alpha-1746832516000",
|
||||||
"yaml": "^2.7.1",
|
|
||||||
"zod-to-json-schema": "^3.24.4"
|
"zod-to-json-schema": "^3.24.4"
|
||||||
},
|
},
|
||||||
"bin": {
|
"bin": {
|
||||||
@@ -21,7 +20,7 @@
|
|||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/eslintrc": "^3.2.0",
|
"@eslint/eslintrc": "^3.2.0",
|
||||||
"@eslint/js": "^9.19.0",
|
"@eslint/js": "^9.19.0",
|
||||||
"@playwright/test": "1.53.0-alpha-1746218818000",
|
"@playwright/test": "1.53.0-alpha-1746832516000",
|
||||||
"@stylistic/eslint-plugin": "^3.0.1",
|
"@stylistic/eslint-plugin": "^3.0.1",
|
||||||
"@types/node": "^22.13.10",
|
"@types/node": "^22.13.10",
|
||||||
"@typescript-eslint/eslint-plugin": "^8.26.1",
|
"@typescript-eslint/eslint-plugin": "^8.26.1",
|
||||||
@@ -287,13 +286,13 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@playwright/test": {
|
"node_modules/@playwright/test": {
|
||||||
"version": "1.53.0-alpha-1746218818000",
|
"version": "1.53.0-alpha-1746832516000",
|
||||||
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.53.0-alpha-1746218818000.tgz",
|
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.53.0-alpha-1746832516000.tgz",
|
||||||
"integrity": "sha512-J05FD0oOCVbjbp4IjQi5tOPKywchi5EENS9jRjgkA5N9jd/+BaZ3jT8HlLMIgALdk/eLsprQa7vh9h45Q1FOPA==",
|
"integrity": "sha512-Sec+6uzpA4MfwmQqJFBFVazffynqVwLO5swDxG7WoqgpUdn9gQX4K4tDG64SV6f4nOpwdM5LKTasPSXu02nn/Q==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"playwright": "1.53.0-alpha-1746218818000"
|
"playwright": "1.53.0-alpha-1746832516000"
|
||||||
},
|
},
|
||||||
"bin": {
|
"bin": {
|
||||||
"playwright": "cli.js"
|
"playwright": "cli.js"
|
||||||
@@ -3299,12 +3298,12 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/playwright": {
|
"node_modules/playwright": {
|
||||||
"version": "1.53.0-alpha-1746218818000",
|
"version": "1.53.0-alpha-1746832516000",
|
||||||
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.53.0-alpha-1746218818000.tgz",
|
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.53.0-alpha-1746832516000.tgz",
|
||||||
"integrity": "sha512-mVIjtdqIawIqWVyvCaLmV6XTALCT4oWWrbMjoHyyWRln3jQjnm3RUO9LkaINz+Yh88O3FkuY6RfjGXPXeFeJ4Q==",
|
"integrity": "sha512-kcC1B2XJr4VaDAcVzi61SbYGkodq1QIqQXuPieXsNgZZ7cEKWzO2sI42yp2yie6wlCx0oLkSS2Q6jWSRVRLeaw==",
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"playwright-core": "1.53.0-alpha-1746218818000"
|
"playwright-core": "1.53.0-alpha-1746832516000"
|
||||||
},
|
},
|
||||||
"bin": {
|
"bin": {
|
||||||
"playwright": "cli.js"
|
"playwright": "cli.js"
|
||||||
@@ -3317,9 +3316,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/playwright-core": {
|
"node_modules/playwright-core": {
|
||||||
"version": "1.53.0-alpha-1746218818000",
|
"version": "1.53.0-alpha-1746832516000",
|
||||||
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.53.0-alpha-1746218818000.tgz",
|
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.53.0-alpha-1746832516000.tgz",
|
||||||
"integrity": "sha512-iaIZmhO/psGssWpxIprJkFrn2h4xFjgL0jZsKGtReAMZ/XhlqMUJxtSitwWM4BV+wxJIptsZD0s5Ml2KU62Z3w==",
|
"integrity": "sha512-4O98y4zV0rOP6CepMLC/VGuzqGaR1sS9AVh+i0CghWMQHM/8bxPJI8W38QndO0JU0V5nBD6j7DQeNt1mJ+CZ+g==",
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"bin": {
|
"bin": {
|
||||||
"playwright-core": "cli.js"
|
"playwright-core": "cli.js"
|
||||||
@@ -4350,18 +4349,6 @@
|
|||||||
"integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==",
|
"integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==",
|
||||||
"license": "ISC"
|
"license": "ISC"
|
||||||
},
|
},
|
||||||
"node_modules/yaml": {
|
|
||||||
"version": "2.7.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/yaml/-/yaml-2.7.1.tgz",
|
|
||||||
"integrity": "sha512-10ULxpnOCQXxJvBgxsn9ptjq6uviG/htZKk9veJGhlqn3w/DxQ631zFF+nlQXLwmImeS5amR2dl2U8sg6U9jsQ==",
|
|
||||||
"license": "ISC",
|
|
||||||
"bin": {
|
|
||||||
"yaml": "bin.mjs"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">= 14"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/yocto-queue": {
|
"node_modules/yocto-queue": {
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@playwright/mcp",
|
"name": "@playwright/mcp",
|
||||||
"version": "0.0.21",
|
"version": "0.0.24",
|
||||||
"description": "Playwright Tools for MCP",
|
"description": "Playwright Tools for MCP",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"repository": {
|
"repository": {
|
||||||
@@ -37,14 +37,13 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@modelcontextprotocol/sdk": "^1.11.0",
|
"@modelcontextprotocol/sdk": "^1.11.0",
|
||||||
"commander": "^13.1.0",
|
"commander": "^13.1.0",
|
||||||
"playwright": "1.53.0-alpha-1746218818000",
|
"playwright": "1.53.0-alpha-1746832516000",
|
||||||
"yaml": "^2.7.1",
|
|
||||||
"zod-to-json-schema": "^3.24.4"
|
"zod-to-json-schema": "^3.24.4"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/eslintrc": "^3.2.0",
|
"@eslint/eslintrc": "^3.2.0",
|
||||||
"@eslint/js": "^9.19.0",
|
"@eslint/js": "^9.19.0",
|
||||||
"@playwright/test": "1.53.0-alpha-1746218818000",
|
"@playwright/test": "1.53.0-alpha-1746832516000",
|
||||||
"@stylistic/eslint-plugin": "^3.0.1",
|
"@stylistic/eslint-plugin": "^3.0.1",
|
||||||
"@types/node": "^22.13.10",
|
"@types/node": "^22.13.10",
|
||||||
"@typescript-eslint/eslint-plugin": "^8.26.1",
|
"@typescript-eslint/eslint-plugin": "^8.26.1",
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ export default defineConfig<TestOptions>({
|
|||||||
{ name: 'chrome' },
|
{ name: 'chrome' },
|
||||||
{ name: 'msedge', use: { mcpBrowser: 'msedge' } },
|
{ name: 'msedge', use: { mcpBrowser: 'msedge' } },
|
||||||
{ name: 'chromium', use: { mcpBrowser: 'chromium' } },
|
{ name: 'chromium', use: { mcpBrowser: 'chromium' } },
|
||||||
|
...process.env.MCP_IN_DOCKER ? [{ name: 'chromium-docker', use: { mcpBrowser: 'chromium', mcpMode: 'docker' as const } }] : [],
|
||||||
{ name: 'firefox', use: { mcpBrowser: 'firefox' } },
|
{ name: 'firefox', use: { mcpBrowser: 'firefox' } },
|
||||||
{ name: 'webkit', use: { mcpBrowser: 'webkit' } },
|
{ name: 'webkit', use: { mcpBrowser: 'webkit' } },
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -99,7 +99,7 @@ export async function configFromCLIOptions(cliOptions: CLIOptions): Promise<Conf
|
|||||||
};
|
};
|
||||||
|
|
||||||
if (browserName === 'chromium')
|
if (browserName === 'chromium')
|
||||||
(launchOptions as any).webSocketPort = await findFreePort();
|
(launchOptions as any).cdpPort = await findFreePort();
|
||||||
|
|
||||||
const contextOptions: BrowserContextOptions | undefined = cliOptions.device ? devices[cliOptions.device] : undefined;
|
const contextOptions: BrowserContextOptions | undefined = cliOptions.device ? devices[cliOptions.device] : undefined;
|
||||||
|
|
||||||
@@ -175,7 +175,7 @@ function mergeConfig(base: Config, overrides: Config): Config {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
if (browser.browserName !== 'chromium')
|
if (browser.browserName !== 'chromium' && browser.launchOptions)
|
||||||
delete browser.launchOptions.channel;
|
delete browser.launchOptions.channel;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -125,7 +125,7 @@ export class Context {
|
|||||||
|
|
||||||
async run(tool: Tool, params: Record<string, unknown> | undefined) {
|
async run(tool: Tool, params: Record<string, unknown> | undefined) {
|
||||||
// Tab management is done outside of the action() call.
|
// Tab management is done outside of the action() call.
|
||||||
const toolResult = await tool.handle(this, tool.schema.inputSchema.parse(params));
|
const toolResult = await tool.handle(this, tool.schema.inputSchema.parse(params || {}));
|
||||||
const { code, action, waitForNetwork, captureSnapshot, resultOverride } = toolResult;
|
const { code, action, waitForNetwork, captureSnapshot, resultOverride } = toolResult;
|
||||||
const racingAction = action ? () => this._raceAgainstModalDialogs(action) : undefined;
|
const racingAction = action ? () => this._raceAgainstModalDialogs(action) : undefined;
|
||||||
|
|
||||||
@@ -374,7 +374,7 @@ async function createUserDataDir(browserConfig: Config['browser']) {
|
|||||||
cacheDirectory = process.env.LOCALAPPDATA || path.join(os.homedir(), 'AppData', 'Local');
|
cacheDirectory = process.env.LOCALAPPDATA || path.join(os.homedir(), 'AppData', 'Local');
|
||||||
else
|
else
|
||||||
throw new Error('Unsupported platform: ' + process.platform);
|
throw new Error('Unsupported platform: ' + process.platform);
|
||||||
const result = path.join(cacheDirectory, 'ms-playwright', `mcp-${browserConfig?.launchOptions.channel ?? browserConfig?.browserName}-profile`);
|
const result = path.join(cacheDirectory, 'ms-playwright', `mcp-${browserConfig?.launchOptions?.channel ?? browserConfig?.browserName}-profile`);
|
||||||
await fs.promises.mkdir(result, { recursive: true });
|
await fs.promises.mkdir(result, { recursive: true });
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,10 +14,10 @@
|
|||||||
* limitations under the License.
|
* limitations under the License.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { Connection } from './connection.js';
|
import { Connection, createConnection as createConnectionImpl } from './connection.js';
|
||||||
|
|
||||||
import type { Config } from '../config.js';
|
import type { Config } from '../config.js';
|
||||||
|
|
||||||
export async function createConnection(config: Config = {}): Promise<Connection> {
|
export async function createConnection(config: Config = {}): Promise<Connection> {
|
||||||
return createConnection(config);
|
return createConnectionImpl(config);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,20 +15,18 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import * as playwright from 'playwright';
|
import * as playwright from 'playwright';
|
||||||
import yaml from 'yaml';
|
|
||||||
|
|
||||||
type PageOrFrameLocator = playwright.Page | playwright.FrameLocator;
|
|
||||||
|
|
||||||
export class PageSnapshot {
|
export class PageSnapshot {
|
||||||
private _frameLocators: PageOrFrameLocator[] = [];
|
private _page: playwright.Page;
|
||||||
private _text!: string;
|
private _text!: string;
|
||||||
|
|
||||||
constructor() {
|
constructor(page: playwright.Page) {
|
||||||
|
this._page = page;
|
||||||
}
|
}
|
||||||
|
|
||||||
static async create(page: playwright.Page): Promise<PageSnapshot> {
|
static async create(page: playwright.Page): Promise<PageSnapshot> {
|
||||||
const snapshot = new PageSnapshot();
|
const snapshot = new PageSnapshot(page);
|
||||||
await snapshot._build(page);
|
await snapshot._build();
|
||||||
return snapshot;
|
return snapshot;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -36,8 +34,8 @@ export class PageSnapshot {
|
|||||||
return this._text;
|
return this._text;
|
||||||
}
|
}
|
||||||
|
|
||||||
private async _build(page: playwright.Page) {
|
private async _build() {
|
||||||
const yamlDocument = await this._snapshotFrame(page);
|
const yamlDocument = await (this._page as any)._snapshotForAI();
|
||||||
this._text = [
|
this._text = [
|
||||||
`- Page Snapshot`,
|
`- Page Snapshot`,
|
||||||
'```yaml',
|
'```yaml',
|
||||||
@@ -46,56 +44,7 @@ export class PageSnapshot {
|
|||||||
].join('\n');
|
].join('\n');
|
||||||
}
|
}
|
||||||
|
|
||||||
private async _snapshotFrame(frame: playwright.Page | playwright.FrameLocator) {
|
|
||||||
const frameIndex = this._frameLocators.push(frame) - 1;
|
|
||||||
const snapshotString = await frame.locator('body').ariaSnapshot({ ref: true, emitGeneric: true });
|
|
||||||
const snapshot = yaml.parseDocument(snapshotString);
|
|
||||||
|
|
||||||
const visit = async (node: any): Promise<unknown> => {
|
|
||||||
if (yaml.isPair(node)) {
|
|
||||||
await Promise.all([
|
|
||||||
visit(node.key).then(k => node.key = k),
|
|
||||||
visit(node.value).then(v => node.value = v)
|
|
||||||
]);
|
|
||||||
} else if (yaml.isSeq(node) || yaml.isMap(node)) {
|
|
||||||
node.items = await Promise.all(node.items.map(visit));
|
|
||||||
} else if (yaml.isScalar(node)) {
|
|
||||||
if (typeof node.value === 'string') {
|
|
||||||
const value = node.value;
|
|
||||||
if (frameIndex > 0)
|
|
||||||
node.value = value.replace('[ref=', `[ref=f${frameIndex}`);
|
|
||||||
if (value.startsWith('iframe ')) {
|
|
||||||
const ref = value.match(/\[ref=(.*)\]/)?.[1];
|
|
||||||
if (ref) {
|
|
||||||
try {
|
|
||||||
const childSnapshot = await this._snapshotFrame(frame.frameLocator(`aria-ref=${ref}`));
|
|
||||||
return snapshot.createPair(node.value, childSnapshot);
|
|
||||||
} catch (error) {
|
|
||||||
return snapshot.createPair(node.value, '<could not take iframe snapshot>');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return node;
|
|
||||||
};
|
|
||||||
await visit(snapshot.contents);
|
|
||||||
return snapshot;
|
|
||||||
}
|
|
||||||
|
|
||||||
refLocator(ref: string): playwright.Locator {
|
refLocator(ref: string): playwright.Locator {
|
||||||
let frame = this._frameLocators[0];
|
return this._page.locator(`aria-ref=${ref}`);
|
||||||
const match = ref.match(/^f(\d+)(.*)/);
|
|
||||||
if (match) {
|
|
||||||
const frameIndex = parseInt(match[1], 10);
|
|
||||||
frame = this._frameLocators[frameIndex];
|
|
||||||
ref = match[2];
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!frame)
|
|
||||||
throw new Error(`Frame does not exist. Provide ref from the most current snapshot.`);
|
|
||||||
|
|
||||||
return frame.locator(`aria-ref=${ref}`);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
37
src/tab.ts
37
src/tab.ts
@@ -23,7 +23,7 @@ import type { Context } from './context.js';
|
|||||||
export class Tab {
|
export class Tab {
|
||||||
readonly context: Context;
|
readonly context: Context;
|
||||||
readonly page: playwright.Page;
|
readonly page: playwright.Page;
|
||||||
private _console: playwright.ConsoleMessage[] = [];
|
private _consoleMessages: playwright.ConsoleMessage[] = [];
|
||||||
private _requests: Map<playwright.Request, playwright.Response | null> = new Map();
|
private _requests: Map<playwright.Request, playwright.Response | null> = new Map();
|
||||||
private _snapshot: PageSnapshot | undefined;
|
private _snapshot: PageSnapshot | undefined;
|
||||||
private _onPageClose: (tab: Tab) => void;
|
private _onPageClose: (tab: Tab) => void;
|
||||||
@@ -32,13 +32,9 @@ export class Tab {
|
|||||||
this.context = context;
|
this.context = context;
|
||||||
this.page = page;
|
this.page = page;
|
||||||
this._onPageClose = onPageClose;
|
this._onPageClose = onPageClose;
|
||||||
page.on('console', event => this._console.push(event));
|
page.on('console', event => this._consoleMessages.push(event));
|
||||||
page.on('request', request => this._requests.set(request, null));
|
page.on('request', request => this._requests.set(request, null));
|
||||||
page.on('response', response => this._requests.set(response.request(), response));
|
page.on('response', response => this._requests.set(response.request(), response));
|
||||||
page.on('framenavigated', frame => {
|
|
||||||
if (!frame.parentFrame())
|
|
||||||
this._clearCollectedArtifacts();
|
|
||||||
});
|
|
||||||
page.on('close', () => this._onClose());
|
page.on('close', () => this._onClose());
|
||||||
page.on('filechooser', chooser => {
|
page.on('filechooser', chooser => {
|
||||||
this.context.setModalState({
|
this.context.setModalState({
|
||||||
@@ -56,7 +52,7 @@ export class Tab {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private _clearCollectedArtifacts() {
|
private _clearCollectedArtifacts() {
|
||||||
this._console.length = 0;
|
this._consoleMessages.length = 0;
|
||||||
this._requests.clear();
|
this._requests.clear();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -66,7 +62,28 @@ export class Tab {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async navigate(url: string) {
|
async navigate(url: string) {
|
||||||
await this.page.goto(url, { waitUntil: 'domcontentloaded' });
|
this._clearCollectedArtifacts();
|
||||||
|
|
||||||
|
const downloadEvent = this.page.waitForEvent('download').catch(() => {});
|
||||||
|
try {
|
||||||
|
await this.page.goto(url, { waitUntil: 'domcontentloaded' });
|
||||||
|
} catch (_e: unknown) {
|
||||||
|
const e = _e as Error;
|
||||||
|
const mightBeDownload =
|
||||||
|
e.message.includes('net::ERR_ABORTED') // chromium
|
||||||
|
|| e.message.includes('Download is starting'); // firefox + webkit
|
||||||
|
if (!mightBeDownload)
|
||||||
|
throw e;
|
||||||
|
|
||||||
|
// on chromium, the download event is fired *after* page.goto rejects, so we wait a lil bit
|
||||||
|
const download = await Promise.race([
|
||||||
|
downloadEvent,
|
||||||
|
new Promise(resolve => setTimeout(resolve, 500)),
|
||||||
|
]);
|
||||||
|
if (!download)
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
|
||||||
// Cap load event to 5 seconds, the page is operational at this point.
|
// Cap load event to 5 seconds, the page is operational at this point.
|
||||||
await this.page.waitForLoadState('load', { timeout: 5000 }).catch(() => {});
|
await this.page.waitForLoadState('load', { timeout: 5000 }).catch(() => {});
|
||||||
}
|
}
|
||||||
@@ -81,8 +98,8 @@ export class Tab {
|
|||||||
return this._snapshot;
|
return this._snapshot;
|
||||||
}
|
}
|
||||||
|
|
||||||
console(): playwright.ConsoleMessage[] {
|
consoleMessages(): playwright.ConsoleMessage[] {
|
||||||
return this._console;
|
return this._consoleMessages;
|
||||||
}
|
}
|
||||||
|
|
||||||
requests(): Map<playwright.Request, playwright.Response | null> {
|
requests(): Map<playwright.Request, playwright.Response | null> {
|
||||||
|
|||||||
@@ -21,19 +21,44 @@ const wait: ToolFactory = captureSnapshot => defineTool({
|
|||||||
capability: 'wait',
|
capability: 'wait',
|
||||||
|
|
||||||
schema: {
|
schema: {
|
||||||
name: 'browser_wait',
|
name: 'browser_wait_for',
|
||||||
title: 'Wait',
|
title: 'Wait for',
|
||||||
description: 'Wait for a specified time in seconds',
|
description: 'Wait for text to appear or disappear or a specified time to pass',
|
||||||
inputSchema: z.object({
|
inputSchema: z.object({
|
||||||
time: z.number().describe('The time to wait in seconds'),
|
time: z.number().optional().describe('The time to wait in seconds'),
|
||||||
|
text: z.string().optional().describe('The text to wait for'),
|
||||||
|
textGone: z.string().optional().describe('The text to wait for to disappear'),
|
||||||
}),
|
}),
|
||||||
type: 'readOnly',
|
type: 'readOnly',
|
||||||
},
|
},
|
||||||
|
|
||||||
handle: async (context, params) => {
|
handle: async (context, params) => {
|
||||||
await new Promise(f => setTimeout(f, Math.min(10000, params.time * 1000)));
|
if (!params.text && !params.textGone && !params.time)
|
||||||
|
throw new Error('Either time, text or textGone must be provided');
|
||||||
|
|
||||||
|
const code: string[] = [];
|
||||||
|
|
||||||
|
if (params.time) {
|
||||||
|
code.push(`await new Promise(f => setTimeout(f, ${params.time!} * 1000));`);
|
||||||
|
await new Promise(f => setTimeout(f, Math.min(10000, params.time! * 1000)));
|
||||||
|
}
|
||||||
|
|
||||||
|
const tab = context.currentTabOrDie();
|
||||||
|
const locator = params.text ? tab.page.getByText(params.text).first() : undefined;
|
||||||
|
const goneLocator = params.textGone ? tab.page.getByText(params.textGone).first() : undefined;
|
||||||
|
|
||||||
|
if (goneLocator) {
|
||||||
|
code.push(`await page.getByText(${JSON.stringify(params.textGone)}).first().waitFor({ state: 'hidden' });`);
|
||||||
|
await goneLocator.waitFor({ state: 'hidden' });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (locator) {
|
||||||
|
code.push(`await page.getByText(${JSON.stringify(params.text)}).first().waitFor({ state: 'visible' });`);
|
||||||
|
await locator.waitFor({ state: 'visible' });
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
code: [`// Waited for ${params.time} seconds`],
|
code,
|
||||||
captureSnapshot,
|
captureSnapshot,
|
||||||
waitForNetwork: false,
|
waitForNetwork: false,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ const console = defineTool({
|
|||||||
type: 'readOnly',
|
type: 'readOnly',
|
||||||
},
|
},
|
||||||
handle: async context => {
|
handle: async context => {
|
||||||
const messages = context.currentTabOrDie().console();
|
const messages = context.currentTabOrDie().consoleMessages();
|
||||||
const log = messages.map(message => `[${message.type().toUpperCase()}] ${message.text()}`).join('\n');
|
const log = messages.map(message => `[${message.type().toUpperCase()}] ${message.text()}`).join('\n');
|
||||||
return {
|
return {
|
||||||
code: [`// <internal code to get console messages>`],
|
code: [`// <internal code to get console messages>`],
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ const install = defineTool({
|
|||||||
},
|
},
|
||||||
|
|
||||||
handle: async context => {
|
handle: async context => {
|
||||||
const channel = context.config.browser?.launchOptions?.channel ?? context.config.browser?.launchOptions.browserName ?? 'chrome';
|
const channel = context.config.browser?.launchOptions?.channel ?? context.config.browser?.browserName ?? 'chrome';
|
||||||
const cliUrl = import.meta.resolve('playwright/package.json');
|
const cliUrl = import.meta.resolve('playwright/package.json');
|
||||||
const cliPath = path.join(fileURLToPath(cliUrl), '..', 'cli.js');
|
const cliPath = path.join(fileURLToPath(cliUrl), '..', 'cli.js');
|
||||||
const child = fork(cliPath, ['install', channel], {
|
const child = fork(cliPath, ['install', channel], {
|
||||||
|
|||||||
@@ -20,6 +20,10 @@ import { defineTool } from './tool.js';
|
|||||||
import * as javascript from '../javascript.js';
|
import * as javascript from '../javascript.js';
|
||||||
import { outputFile } from '../config.js';
|
import { outputFile } from '../config.js';
|
||||||
|
|
||||||
|
const pdfSchema = z.object({
|
||||||
|
filename: z.string().optional().describe('File name to save the pdf to. Defaults to `page-{timestamp}.pdf` if not specified.'),
|
||||||
|
});
|
||||||
|
|
||||||
const pdf = defineTool({
|
const pdf = defineTool({
|
||||||
capability: 'pdf',
|
capability: 'pdf',
|
||||||
|
|
||||||
@@ -27,13 +31,13 @@ const pdf = defineTool({
|
|||||||
name: 'browser_pdf_save',
|
name: 'browser_pdf_save',
|
||||||
title: 'Save as PDF',
|
title: 'Save as PDF',
|
||||||
description: 'Save page as PDF',
|
description: 'Save page as PDF',
|
||||||
inputSchema: z.object({}),
|
inputSchema: pdfSchema,
|
||||||
type: 'readOnly',
|
type: 'readOnly',
|
||||||
},
|
},
|
||||||
|
|
||||||
handle: async context => {
|
handle: async (context, params) => {
|
||||||
const tab = context.currentTabOrDie();
|
const tab = context.currentTabOrDie();
|
||||||
const fileName = await outputFile(context.config, `page-${new Date().toISOString()}.pdf`);
|
const fileName = await outputFile(context.config, params.filename ?? `page-${new Date().toISOString()}.pdf`);
|
||||||
|
|
||||||
const code = [
|
const code = [
|
||||||
`// Save page as ${fileName}`,
|
`// Save page as ${fileName}`,
|
||||||
|
|||||||
@@ -220,6 +220,7 @@ const selectOption = defineTool({
|
|||||||
|
|
||||||
const screenshotSchema = z.object({
|
const screenshotSchema = z.object({
|
||||||
raw: z.boolean().optional().describe('Whether to return without compression (in PNG format). Default is false, which returns a JPEG image.'),
|
raw: z.boolean().optional().describe('Whether to return without compression (in PNG format). Default is false, which returns a JPEG image.'),
|
||||||
|
filename: z.string().optional().describe('File name to save the screenshot to. Defaults to `page-{timestamp}.{png|jpeg}` if not specified.'),
|
||||||
element: z.string().optional().describe('Human-readable element description used to obtain permission to screenshot the element. If not provided, the screenshot will be taken of viewport. If element is provided, ref must be provided too.'),
|
element: z.string().optional().describe('Human-readable element description used to obtain permission to screenshot the element. If not provided, the screenshot will be taken of viewport. If element is provided, ref must be provided too.'),
|
||||||
ref: z.string().optional().describe('Exact target element reference from the page snapshot. If not provided, the screenshot will be taken of viewport. If ref is provided, element must be provided too.'),
|
ref: z.string().optional().describe('Exact target element reference from the page snapshot. If not provided, the screenshot will be taken of viewport. If ref is provided, element must be provided too.'),
|
||||||
}).refine(data => {
|
}).refine(data => {
|
||||||
@@ -243,7 +244,7 @@ const screenshot = defineTool({
|
|||||||
const tab = context.currentTabOrDie();
|
const tab = context.currentTabOrDie();
|
||||||
const snapshot = tab.snapshotOrDie();
|
const snapshot = tab.snapshotOrDie();
|
||||||
const fileType = params.raw ? 'png' : 'jpeg';
|
const fileType = params.raw ? 'png' : 'jpeg';
|
||||||
const fileName = await outputFile(context.config, `page-${new Date().toISOString()}.${fileType}`);
|
const fileName = await outputFile(context.config, params.filename ?? `page-${new Date().toISOString()}.${fileType}`);
|
||||||
const options: playwright.PageScreenshotOptions = { type: fileType, quality: fileType === 'png' ? undefined : 50, scale: 'css', path: fileName };
|
const options: playwright.PageScreenshotOptions = { type: fileType, quality: fileType === 'png' ? undefined : 50, scale: 'css', path: fileName };
|
||||||
const isElementScreenshot = params.element && params.ref;
|
const isElementScreenshot = params.element && params.ref;
|
||||||
|
|
||||||
|
|||||||
@@ -43,7 +43,7 @@ test('test snapshot tool list', async ({ client }) => {
|
|||||||
'browser_tab_new',
|
'browser_tab_new',
|
||||||
'browser_tab_select',
|
'browser_tab_select',
|
||||||
'browser_take_screenshot',
|
'browser_take_screenshot',
|
||||||
'browser_wait',
|
'browser_wait_for',
|
||||||
]));
|
]));
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -72,7 +72,7 @@ test('test vision tool list', async ({ visionClient }) => {
|
|||||||
'browser_tab_list',
|
'browser_tab_list',
|
||||||
'browser_tab_new',
|
'browser_tab_new',
|
||||||
'browser_tab_select',
|
'browser_tab_select',
|
||||||
'browser_wait',
|
'browser_wait_for',
|
||||||
]));
|
]));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -16,14 +16,12 @@
|
|||||||
|
|
||||||
import { test, expect } from './fixtures.js';
|
import { test, expect } from './fixtures.js';
|
||||||
|
|
||||||
test('cdp server', async ({ cdpEndpoint, startClient }) => {
|
test('cdp server', async ({ cdpEndpoint, startClient, server }) => {
|
||||||
const client = await startClient({ args: [`--cdp-endpoint=${await cdpEndpoint()}`] });
|
const client = await startClient({ args: [`--cdp-endpoint=${await cdpEndpoint()}`] });
|
||||||
expect(await client.callTool({
|
expect(await client.callTool({
|
||||||
name: 'browser_navigate',
|
name: 'browser_navigate',
|
||||||
arguments: {
|
arguments: { url: server.HELLO_WORLD },
|
||||||
url: 'data:text/html,<html><title>Title</title><body>Hello, world!</body></html>',
|
})).toContainTextContent(`- generic [ref=e1]: Hello, world!`);
|
||||||
},
|
|
||||||
})).toContainTextContent(`- generic [ref=s1e2]: Hello, world!`);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test('cdp server reuse tab', async ({ cdpEndpoint, startClient }) => {
|
test('cdp server reuse tab', async ({ cdpEndpoint, startClient }) => {
|
||||||
@@ -39,7 +37,6 @@ test('cdp server reuse tab', async ({ cdpEndpoint, startClient }) => {
|
|||||||
|
|
||||||
expect(await client.callTool({
|
expect(await client.callTool({
|
||||||
name: 'browser_snapshot',
|
name: 'browser_snapshot',
|
||||||
arguments: {},
|
|
||||||
})).toHaveTextContent(`
|
})).toHaveTextContent(`
|
||||||
- Ran Playwright code:
|
- Ran Playwright code:
|
||||||
\`\`\`js
|
\`\`\`js
|
||||||
@@ -50,25 +47,27 @@ test('cdp server reuse tab', async ({ cdpEndpoint, startClient }) => {
|
|||||||
- Page Title:
|
- Page Title:
|
||||||
- Page Snapshot
|
- Page Snapshot
|
||||||
\`\`\`yaml
|
\`\`\`yaml
|
||||||
- generic [ref=s1e2]: hello world
|
- generic [ref=e1]: hello world
|
||||||
\`\`\`
|
\`\`\`
|
||||||
`);
|
`);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('should throw connection error and allow re-connecting', async ({ cdpEndpoint, startClient }) => {
|
test('should throw connection error and allow re-connecting', async ({ cdpEndpoint, startClient, server }) => {
|
||||||
const port = 3200 + test.info().parallelIndex;
|
const port = 3200 + test.info().parallelIndex;
|
||||||
const client = await startClient({ args: [`--cdp-endpoint=http://localhost:${port}`] });
|
const client = await startClient({ args: [`--cdp-endpoint=http://localhost:${port}`] });
|
||||||
|
|
||||||
|
server.setContent('/', `
|
||||||
|
<title>Title</title>
|
||||||
|
<body>Hello, world!</body>
|
||||||
|
`, 'text/html');
|
||||||
|
|
||||||
expect(await client.callTool({
|
expect(await client.callTool({
|
||||||
name: 'browser_navigate',
|
name: 'browser_navigate',
|
||||||
arguments: {
|
arguments: { url: server.PREFIX },
|
||||||
url: 'data:text/html,<html><title>Title</title><body>Hello, world!</body></html>',
|
|
||||||
},
|
|
||||||
})).toContainTextContent(`Error: browserType.connectOverCDP: connect ECONNREFUSED`);
|
})).toContainTextContent(`Error: browserType.connectOverCDP: connect ECONNREFUSED`);
|
||||||
await cdpEndpoint(port);
|
await cdpEndpoint(port);
|
||||||
expect(await client.callTool({
|
expect(await client.callTool({
|
||||||
name: 'browser_navigate',
|
name: 'browser_navigate',
|
||||||
arguments: {
|
arguments: { url: server.PREFIX },
|
||||||
url: 'data:text/html,<html><title>Title</title><body>Hello, world!</body></html>',
|
})).toContainTextContent(`- generic [ref=e1]: Hello, world!`);
|
||||||
},
|
|
||||||
})).toContainTextContent(`- generic [ref=s1e2]: Hello, world!`);
|
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -19,21 +19,24 @@ import fs from 'node:fs';
|
|||||||
import { Config } from '../config.js';
|
import { Config } from '../config.js';
|
||||||
import { test, expect } from './fixtures.js';
|
import { test, expect } from './fixtures.js';
|
||||||
|
|
||||||
test('config user data dir', async ({ startClient, mcpBrowser }, testInfo) => {
|
test('config user data dir', async ({ startClient, localOutputPath, server }) => {
|
||||||
|
server.setContent('/', `
|
||||||
|
<title>Title</title>
|
||||||
|
<body>Hello, world!</body>
|
||||||
|
`, 'text/html');
|
||||||
|
|
||||||
const config: Config = {
|
const config: Config = {
|
||||||
browser: {
|
browser: {
|
||||||
userDataDir: testInfo.outputPath('user-data-dir'),
|
userDataDir: localOutputPath('user-data-dir'),
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
const configPath = testInfo.outputPath('config.json');
|
const configPath = localOutputPath('config.json');
|
||||||
await fs.promises.writeFile(configPath, JSON.stringify(config, null, 2));
|
await fs.promises.writeFile(configPath, JSON.stringify(config, null, 2));
|
||||||
|
|
||||||
const client = await startClient({ args: ['--config', configPath] });
|
const client = await startClient({ args: ['--config', configPath] });
|
||||||
expect(await client.callTool({
|
expect(await client.callTool({
|
||||||
name: 'browser_navigate',
|
name: 'browser_navigate',
|
||||||
arguments: {
|
arguments: { url: server.PREFIX },
|
||||||
url: 'data:text/html,<html><body>Hello, world!</body></html>',
|
|
||||||
},
|
|
||||||
})).toContainTextContent(`Hello, world!`);
|
})).toContainTextContent(`Hello, world!`);
|
||||||
|
|
||||||
const files = await fs.promises.readdir(config.browser!.userDataDir!);
|
const files = await fs.promises.readdir(config.browser!.userDataDir!);
|
||||||
|
|||||||
@@ -16,17 +16,26 @@
|
|||||||
|
|
||||||
import { test, expect } from './fixtures.js';
|
import { test, expect } from './fixtures.js';
|
||||||
|
|
||||||
test('browser_console_messages', async ({ client }) => {
|
test('browser_console_messages', async ({ client, server }) => {
|
||||||
|
server.setContent('/', `
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<script>
|
||||||
|
console.log("Hello, world!");
|
||||||
|
console.error("Error");
|
||||||
|
</script>
|
||||||
|
</html>
|
||||||
|
`, 'text/html');
|
||||||
|
|
||||||
await client.callTool({
|
await client.callTool({
|
||||||
name: 'browser_navigate',
|
name: 'browser_navigate',
|
||||||
arguments: {
|
arguments: {
|
||||||
url: 'data:text/html,<html><script>console.log("Hello, world!");console.error("Error"); </script></html>',
|
url: server.PREFIX,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const resource = await client.callTool({
|
const resource = await client.callTool({
|
||||||
name: 'browser_console_messages',
|
name: 'browser_console_messages',
|
||||||
arguments: {},
|
|
||||||
});
|
});
|
||||||
expect(resource).toHaveTextContent([
|
expect(resource).toHaveTextContent([
|
||||||
'[LOG] Hello, world!',
|
'[LOG] Hello, world!',
|
||||||
|
|||||||
@@ -16,42 +16,43 @@
|
|||||||
|
|
||||||
import { test, expect } from './fixtures.js';
|
import { test, expect } from './fixtures.js';
|
||||||
|
|
||||||
test('browser_navigate', async ({ client }) => {
|
test('browser_navigate', async ({ client, server }) => {
|
||||||
expect(await client.callTool({
|
expect(await client.callTool({
|
||||||
name: 'browser_navigate',
|
name: 'browser_navigate',
|
||||||
arguments: {
|
arguments: { url: server.HELLO_WORLD },
|
||||||
url: 'data:text/html,<html><title>Title</title><body>Hello, world!</body></html>',
|
|
||||||
},
|
|
||||||
})).toHaveTextContent(`
|
})).toHaveTextContent(`
|
||||||
- Ran Playwright code:
|
- Ran Playwright code:
|
||||||
\`\`\`js
|
\`\`\`js
|
||||||
// Navigate to data:text/html,<html><title>Title</title><body>Hello, world!</body></html>
|
// Navigate to ${server.HELLO_WORLD}
|
||||||
await page.goto('data:text/html,<html><title>Title</title><body>Hello, world!</body></html>');
|
await page.goto('${server.HELLO_WORLD}');
|
||||||
\`\`\`
|
\`\`\`
|
||||||
|
|
||||||
- Page URL: data:text/html,<html><title>Title</title><body>Hello, world!</body></html>
|
- Page URL: ${server.HELLO_WORLD}
|
||||||
- Page Title: Title
|
- Page Title: Title
|
||||||
- Page Snapshot
|
- Page Snapshot
|
||||||
\`\`\`yaml
|
\`\`\`yaml
|
||||||
- generic [ref=s1e2]: Hello, world!
|
- generic [ref=e1]: Hello, world!
|
||||||
\`\`\`
|
\`\`\`
|
||||||
`
|
`
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('browser_click', async ({ client }) => {
|
test('browser_click', async ({ client, server }) => {
|
||||||
|
server.setContent('/', `
|
||||||
|
<title>Title</title>
|
||||||
|
<button>Submit</button>
|
||||||
|
`, 'text/html');
|
||||||
|
|
||||||
await client.callTool({
|
await client.callTool({
|
||||||
name: 'browser_navigate',
|
name: 'browser_navigate',
|
||||||
arguments: {
|
arguments: { url: server.PREFIX },
|
||||||
url: 'data:text/html,<html><title>Title</title><button>Submit</button></html>',
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(await client.callTool({
|
expect(await client.callTool({
|
||||||
name: 'browser_click',
|
name: 'browser_click',
|
||||||
arguments: {
|
arguments: {
|
||||||
element: 'Submit button',
|
element: 'Submit button',
|
||||||
ref: 's1e3',
|
ref: 'e2',
|
||||||
},
|
},
|
||||||
})).toHaveTextContent(`
|
})).toHaveTextContent(`
|
||||||
- Ran Playwright code:
|
- Ran Playwright code:
|
||||||
@@ -60,28 +61,34 @@ test('browser_click', async ({ client }) => {
|
|||||||
await page.getByRole('button', { name: 'Submit' }).click();
|
await page.getByRole('button', { name: 'Submit' }).click();
|
||||||
\`\`\`
|
\`\`\`
|
||||||
|
|
||||||
- Page URL: data:text/html,<html><title>Title</title><button>Submit</button></html>
|
- Page URL: ${server.PREFIX}
|
||||||
- Page Title: Title
|
- Page Title: Title
|
||||||
- Page Snapshot
|
- Page Snapshot
|
||||||
\`\`\`yaml
|
\`\`\`yaml
|
||||||
- button "Submit" [ref=s2e3]
|
- button "Submit" [ref=e2]
|
||||||
\`\`\`
|
\`\`\`
|
||||||
`);
|
`);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('browser_select_option', async ({ client }) => {
|
test('browser_select_option', async ({ client, server }) => {
|
||||||
|
server.setContent('/', `
|
||||||
|
<title>Title</title>
|
||||||
|
<select>
|
||||||
|
<option value="foo">Foo</option>
|
||||||
|
<option value="bar">Bar</option>
|
||||||
|
</select>
|
||||||
|
`, 'text/html');
|
||||||
|
|
||||||
await client.callTool({
|
await client.callTool({
|
||||||
name: 'browser_navigate',
|
name: 'browser_navigate',
|
||||||
arguments: {
|
arguments: { url: server.PREFIX },
|
||||||
url: 'data:text/html,<html><title>Title</title><select><option value="foo">Foo</option><option value="bar">Bar</option></select></html>',
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(await client.callTool({
|
expect(await client.callTool({
|
||||||
name: 'browser_select_option',
|
name: 'browser_select_option',
|
||||||
arguments: {
|
arguments: {
|
||||||
element: 'Select',
|
element: 'Select',
|
||||||
ref: 's1e3',
|
ref: 'e2',
|
||||||
values: ['bar'],
|
values: ['bar'],
|
||||||
},
|
},
|
||||||
})).toHaveTextContent(`
|
})).toHaveTextContent(`
|
||||||
@@ -91,30 +98,37 @@ test('browser_select_option', async ({ client }) => {
|
|||||||
await page.getByRole('combobox').selectOption(['bar']);
|
await page.getByRole('combobox').selectOption(['bar']);
|
||||||
\`\`\`
|
\`\`\`
|
||||||
|
|
||||||
- Page URL: data:text/html,<html><title>Title</title><select><option value="foo">Foo</option><option value="bar">Bar</option></select></html>
|
- Page URL: ${server.PREFIX}
|
||||||
- Page Title: Title
|
- Page Title: Title
|
||||||
- Page Snapshot
|
- Page Snapshot
|
||||||
\`\`\`yaml
|
\`\`\`yaml
|
||||||
- combobox [ref=s2e3]:
|
- combobox [ref=e2]:
|
||||||
- option "Foo"
|
- option "Foo"
|
||||||
- option "Bar" [selected]
|
- option "Bar" [selected]
|
||||||
\`\`\`
|
\`\`\`
|
||||||
`);
|
`);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('browser_select_option (multiple)', async ({ client }) => {
|
test('browser_select_option (multiple)', async ({ client, server }) => {
|
||||||
|
server.setContent('/', `
|
||||||
|
<title>Title</title>
|
||||||
|
<select multiple>
|
||||||
|
<option value="foo">Foo</option>
|
||||||
|
<option value="bar">Bar</option>
|
||||||
|
<option value="baz">Baz</option>
|
||||||
|
</select>
|
||||||
|
`, 'text/html');
|
||||||
|
|
||||||
await client.callTool({
|
await client.callTool({
|
||||||
name: 'browser_navigate',
|
name: 'browser_navigate',
|
||||||
arguments: {
|
arguments: { url: server.PREFIX },
|
||||||
url: 'data:text/html,<html><title>Title</title><select multiple><option value="foo">Foo</option><option value="bar">Bar</option><option value="baz">Baz</option></select></html>',
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(await client.callTool({
|
expect(await client.callTool({
|
||||||
name: 'browser_select_option',
|
name: 'browser_select_option',
|
||||||
arguments: {
|
arguments: {
|
||||||
element: 'Select',
|
element: 'Select',
|
||||||
ref: 's1e3',
|
ref: 'e2',
|
||||||
values: ['bar', 'baz'],
|
values: ['bar', 'baz'],
|
||||||
},
|
},
|
||||||
})).toHaveTextContent(`
|
})).toHaveTextContent(`
|
||||||
@@ -124,52 +138,62 @@ test('browser_select_option (multiple)', async ({ client }) => {
|
|||||||
await page.getByRole('listbox').selectOption(['bar', 'baz']);
|
await page.getByRole('listbox').selectOption(['bar', 'baz']);
|
||||||
\`\`\`
|
\`\`\`
|
||||||
|
|
||||||
- Page URL: data:text/html,<html><title>Title</title><select multiple><option value="foo">Foo</option><option value="bar">Bar</option><option value="baz">Baz</option></select></html>
|
- Page URL: ${server.PREFIX}
|
||||||
- Page Title: Title
|
- Page Title: Title
|
||||||
- Page Snapshot
|
- Page Snapshot
|
||||||
\`\`\`yaml
|
\`\`\`yaml
|
||||||
- listbox [ref=s2e3]:
|
- listbox [ref=e2]:
|
||||||
- option "Foo" [ref=s2e4]
|
- option "Foo" [ref=e3]
|
||||||
- option "Bar" [selected] [ref=s2e5]
|
- option "Bar" [selected] [ref=e4]
|
||||||
- option "Baz" [selected] [ref=s2e6]
|
- option "Baz" [selected] [ref=e5]
|
||||||
\`\`\`
|
\`\`\`
|
||||||
`);
|
`);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('browser_type', async ({ client }) => {
|
test('browser_type', async ({ client, server }) => {
|
||||||
|
server.setContent('/', `
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<input type='keypress' onkeypress="console.log('Key pressed:', event.key, ', Text:', event.target.value)"></input>
|
||||||
|
</html>
|
||||||
|
`, 'text/html');
|
||||||
|
|
||||||
await client.callTool({
|
await client.callTool({
|
||||||
name: 'browser_navigate',
|
name: 'browser_navigate',
|
||||||
arguments: {
|
arguments: {
|
||||||
url: `data:text/html,<input type='keypress' onkeypress="console.log('Key pressed:', event.key, ', Text:', event.target.value)"></input>`,
|
url: server.PREFIX,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
await client.callTool({
|
await client.callTool({
|
||||||
name: 'browser_type',
|
name: 'browser_type',
|
||||||
arguments: {
|
arguments: {
|
||||||
element: 'textbox',
|
element: 'textbox',
|
||||||
ref: 's1e3',
|
ref: 'e2',
|
||||||
text: 'Hi!',
|
text: 'Hi!',
|
||||||
submit: true,
|
submit: true,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
expect(await client.callTool({
|
expect(await client.callTool({
|
||||||
name: 'browser_console_messages',
|
name: 'browser_console_messages',
|
||||||
arguments: {},
|
|
||||||
})).toHaveTextContent('[LOG] Key pressed: Enter , Text: Hi!');
|
})).toHaveTextContent('[LOG] Key pressed: Enter , Text: Hi!');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('browser_type (slowly)', async ({ client }) => {
|
test('browser_type (slowly)', async ({ client, server }) => {
|
||||||
|
server.setContent('/', `
|
||||||
|
<input type='text' onkeydown="console.log('Key pressed:', event.key, 'Text:', event.target.value)"></input>
|
||||||
|
`, 'text/html');
|
||||||
|
|
||||||
await client.callTool({
|
await client.callTool({
|
||||||
name: 'browser_navigate',
|
name: 'browser_navigate',
|
||||||
arguments: {
|
arguments: {
|
||||||
url: `data:text/html,<input type='text' onkeydown="console.log('Key pressed:', event.key, 'Text:', event.target.value)"></input>`,
|
url: server.PREFIX,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
await client.callTool({
|
await client.callTool({
|
||||||
name: 'browser_type',
|
name: 'browser_type',
|
||||||
arguments: {
|
arguments: {
|
||||||
element: 'textbox',
|
element: 'textbox',
|
||||||
ref: 's1e3',
|
ref: 'e2',
|
||||||
text: 'Hi!',
|
text: 'Hi!',
|
||||||
submit: true,
|
submit: true,
|
||||||
slowly: true,
|
slowly: true,
|
||||||
@@ -177,7 +201,6 @@ test('browser_type (slowly)', async ({ client }) => {
|
|||||||
});
|
});
|
||||||
expect(await client.callTool({
|
expect(await client.callTool({
|
||||||
name: 'browser_console_messages',
|
name: 'browser_console_messages',
|
||||||
arguments: {},
|
|
||||||
})).toHaveTextContent([
|
})).toHaveTextContent([
|
||||||
'[LOG] Key pressed: H Text: ',
|
'[LOG] Key pressed: H Text: ',
|
||||||
'[LOG] Key pressed: i Text: H',
|
'[LOG] Key pressed: i Text: H',
|
||||||
@@ -186,12 +209,18 @@ test('browser_type (slowly)', async ({ client }) => {
|
|||||||
].join('\n'));
|
].join('\n'));
|
||||||
});
|
});
|
||||||
|
|
||||||
test('browser_resize', async ({ client }) => {
|
test('browser_resize', async ({ client, server }) => {
|
||||||
|
server.setContent('/', `
|
||||||
|
<title>Resize Test</title>
|
||||||
|
<body>
|
||||||
|
<div id="size">Waiting for resize...</div>
|
||||||
|
<script>new ResizeObserver(() => { document.getElementById("size").textContent = \`Window size: \${window.innerWidth}x\${window.innerHeight}\`; }).observe(document.body);
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
`, 'text/html');
|
||||||
await client.callTool({
|
await client.callTool({
|
||||||
name: 'browser_navigate',
|
name: 'browser_navigate',
|
||||||
arguments: {
|
arguments: { url: server.PREFIX },
|
||||||
url: 'data:text/html,<html><title>Resize Test</title><body><div id="size">Waiting for resize...</div><script>new ResizeObserver(() => { document.getElementById("size").textContent = `Window size: ${window.innerWidth}x${window.innerHeight}`; }).observe(document.body);</script></body></html>',
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const response = await client.callTool({
|
const response = await client.callTool({
|
||||||
@@ -206,5 +235,5 @@ test('browser_resize', async ({ client }) => {
|
|||||||
// Resize browser window to 390x780
|
// Resize browser window to 390x780
|
||||||
await page.setViewportSize({ width: 390, height: 780 });
|
await page.setViewportSize({ width: 390, height: 780 });
|
||||||
\`\`\``);
|
\`\`\``);
|
||||||
await expect.poll(() => client.callTool({ name: 'browser_snapshot', arguments: {} })).toContainTextContent('Window size: 390x780');
|
await expect.poll(() => client.callTool({ name: 'browser_snapshot' })).toContainTextContent('Window size: 390x780');
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -19,19 +19,18 @@ import { test, expect } from './fixtures.js';
|
|||||||
// https://github.com/microsoft/playwright/issues/35663
|
// https://github.com/microsoft/playwright/issues/35663
|
||||||
test.skip(({ mcpBrowser, mcpHeadless }) => mcpBrowser === 'webkit' && mcpHeadless);
|
test.skip(({ mcpBrowser, mcpHeadless }) => mcpBrowser === 'webkit' && mcpHeadless);
|
||||||
|
|
||||||
test('alert dialog', async ({ client }) => {
|
test('alert dialog', async ({ client, server }) => {
|
||||||
|
server.setContent('/', `<button onclick="alert('Alert')">Button</button>`, 'text/html');
|
||||||
expect(await client.callTool({
|
expect(await client.callTool({
|
||||||
name: 'browser_navigate',
|
name: 'browser_navigate',
|
||||||
arguments: {
|
arguments: { url: server.PREFIX },
|
||||||
url: 'data:text/html,<html><title>Title</title><button onclick="alert(\'Alert\')">Button</button></html>',
|
})).toContainTextContent('- button "Button" [ref=e2]');
|
||||||
},
|
|
||||||
})).toContainTextContent('- button "Button" [ref=s1e3]');
|
|
||||||
|
|
||||||
expect(await client.callTool({
|
expect(await client.callTool({
|
||||||
name: 'browser_click',
|
name: 'browser_click',
|
||||||
arguments: {
|
arguments: {
|
||||||
element: 'Button',
|
element: 'Button',
|
||||||
ref: 's1e3',
|
ref: 'e2',
|
||||||
},
|
},
|
||||||
})).toHaveTextContent(`- Ran Playwright code:
|
})).toHaveTextContent(`- Ran Playwright code:
|
||||||
\`\`\`js
|
\`\`\`js
|
||||||
@@ -55,29 +54,35 @@ await page.getByRole('button', { name: 'Button' }).click();
|
|||||||
// <internal code to handle "alert" dialog>
|
// <internal code to handle "alert" dialog>
|
||||||
\`\`\`
|
\`\`\`
|
||||||
|
|
||||||
- Page URL: data:text/html,<html><title>Title</title><button onclick="alert('Alert')">Button</button></html>
|
- Page URL: ${server.PREFIX}
|
||||||
- Page Title: Title
|
- Page Title:
|
||||||
- Page Snapshot
|
- Page Snapshot
|
||||||
\`\`\`yaml
|
\`\`\`yaml
|
||||||
- button "Button" [ref=s2e3]
|
- button "Button" [ref=e2]
|
||||||
\`\`\`
|
\`\`\`
|
||||||
`);
|
`);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('two alert dialogs', async ({ client }) => {
|
test('two alert dialogs', async ({ client, server }) => {
|
||||||
test.fixme(true, 'Race between the dialog and ariaSnapshot');
|
test.fixme(true, 'Race between the dialog and ariaSnapshot');
|
||||||
|
|
||||||
|
server.setContent('/', `
|
||||||
|
<title>Title</title>
|
||||||
|
<body>
|
||||||
|
<button onclick="alert('Alert 1');alert('Alert 2');">Button</button>
|
||||||
|
</body>
|
||||||
|
`, 'text/html');
|
||||||
|
|
||||||
expect(await client.callTool({
|
expect(await client.callTool({
|
||||||
name: 'browser_navigate',
|
name: 'browser_navigate',
|
||||||
arguments: {
|
arguments: { url: server.PREFIX },
|
||||||
url: 'data:text/html,<html><title>Title</title><button onclick="alert(\'Alert 1\');alert(\'Alert 2\');">Button</button></html>',
|
})).toContainTextContent('- button "Button" [ref=e2]');
|
||||||
},
|
|
||||||
})).toContainTextContent('- button "Button" [ref=s1e3]');
|
|
||||||
|
|
||||||
expect(await client.callTool({
|
expect(await client.callTool({
|
||||||
name: 'browser_click',
|
name: 'browser_click',
|
||||||
arguments: {
|
arguments: {
|
||||||
element: 'Button',
|
element: 'Button',
|
||||||
ref: 's1e3',
|
ref: 'e2',
|
||||||
},
|
},
|
||||||
})).toHaveTextContent(`- Ran Playwright code:
|
})).toHaveTextContent(`- Ran Playwright code:
|
||||||
\`\`\`js
|
\`\`\`js
|
||||||
@@ -98,19 +103,24 @@ await page.getByRole('button', { name: 'Button' }).click();
|
|||||||
expect(result).not.toContainTextContent('### Modal state');
|
expect(result).not.toContainTextContent('### Modal state');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('confirm dialog (true)', async ({ client }) => {
|
test('confirm dialog (true)', async ({ client, server }) => {
|
||||||
|
server.setContent('/', `
|
||||||
|
<title>Title</title>
|
||||||
|
<body>
|
||||||
|
<button onclick="document.body.textContent = confirm('Confirm')">Button</button>
|
||||||
|
</body>
|
||||||
|
`, 'text/html');
|
||||||
|
|
||||||
expect(await client.callTool({
|
expect(await client.callTool({
|
||||||
name: 'browser_navigate',
|
name: 'browser_navigate',
|
||||||
arguments: {
|
arguments: { url: server.PREFIX },
|
||||||
url: 'data:text/html,<html><title>Title</title><button onclick="document.body.textContent = confirm(\'Confirm\')">Button</button></html>',
|
})).toContainTextContent('- button "Button" [ref=e2]');
|
||||||
},
|
|
||||||
})).toContainTextContent('- button "Button" [ref=s1e3]');
|
|
||||||
|
|
||||||
expect(await client.callTool({
|
expect(await client.callTool({
|
||||||
name: 'browser_click',
|
name: 'browser_click',
|
||||||
arguments: {
|
arguments: {
|
||||||
element: 'Button',
|
element: 'Button',
|
||||||
ref: 's1e3',
|
ref: 'e2',
|
||||||
},
|
},
|
||||||
})).toContainTextContent(`### Modal state
|
})).toContainTextContent(`### Modal state
|
||||||
- ["confirm" dialog with message "Confirm"]: can be handled by the "browser_handle_dialog" tool`);
|
- ["confirm" dialog with message "Confirm"]: can be handled by the "browser_handle_dialog" tool`);
|
||||||
@@ -126,23 +136,28 @@ test('confirm dialog (true)', async ({ client }) => {
|
|||||||
expect(result).toContainTextContent('// <internal code to handle "confirm" dialog>');
|
expect(result).toContainTextContent('// <internal code to handle "confirm" dialog>');
|
||||||
expect(result).toContainTextContent(`- Page Snapshot
|
expect(result).toContainTextContent(`- Page Snapshot
|
||||||
\`\`\`yaml
|
\`\`\`yaml
|
||||||
- generic [ref=s2e2]: "true"
|
- generic [ref=e1]: "true"
|
||||||
\`\`\``);
|
\`\`\``);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('confirm dialog (false)', async ({ client }) => {
|
test('confirm dialog (false)', async ({ client, server }) => {
|
||||||
|
server.setContent('/', `
|
||||||
|
<title>Title</title>
|
||||||
|
<body>
|
||||||
|
<button onclick="document.body.textContent = confirm('Confirm')">Button</button>
|
||||||
|
</body>
|
||||||
|
`, 'text/html');
|
||||||
|
|
||||||
expect(await client.callTool({
|
expect(await client.callTool({
|
||||||
name: 'browser_navigate',
|
name: 'browser_navigate',
|
||||||
arguments: {
|
arguments: { url: server.PREFIX },
|
||||||
url: 'data:text/html,<html><title>Title</title><button onclick="document.body.textContent = confirm(\'Confirm\')">Button</button></html>',
|
})).toContainTextContent('- button "Button" [ref=e2]');
|
||||||
},
|
|
||||||
})).toContainTextContent('- button "Button" [ref=s1e3]');
|
|
||||||
|
|
||||||
expect(await client.callTool({
|
expect(await client.callTool({
|
||||||
name: 'browser_click',
|
name: 'browser_click',
|
||||||
arguments: {
|
arguments: {
|
||||||
element: 'Button',
|
element: 'Button',
|
||||||
ref: 's1e3',
|
ref: 'e2',
|
||||||
},
|
},
|
||||||
})).toContainTextContent(`### Modal state
|
})).toContainTextContent(`### Modal state
|
||||||
- ["confirm" dialog with message "Confirm"]: can be handled by the "browser_handle_dialog" tool`);
|
- ["confirm" dialog with message "Confirm"]: can be handled by the "browser_handle_dialog" tool`);
|
||||||
@@ -156,23 +171,28 @@ test('confirm dialog (false)', async ({ client }) => {
|
|||||||
|
|
||||||
expect(result).toContainTextContent(`- Page Snapshot
|
expect(result).toContainTextContent(`- Page Snapshot
|
||||||
\`\`\`yaml
|
\`\`\`yaml
|
||||||
- generic [ref=s2e2]: "false"
|
- generic [ref=e1]: "false"
|
||||||
\`\`\``);
|
\`\`\``);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('prompt dialog', async ({ client }) => {
|
test('prompt dialog', async ({ client, server }) => {
|
||||||
|
server.setContent('/', `
|
||||||
|
<title>Title</title>
|
||||||
|
<body>
|
||||||
|
<button onclick="document.body.textContent = prompt('Prompt')">Button</button>
|
||||||
|
</body>
|
||||||
|
`, 'text/html');
|
||||||
|
|
||||||
expect(await client.callTool({
|
expect(await client.callTool({
|
||||||
name: 'browser_navigate',
|
name: 'browser_navigate',
|
||||||
arguments: {
|
arguments: { url: server.PREFIX },
|
||||||
url: 'data:text/html,<html><title>Title</title><button onclick="document.body.textContent = prompt(\'Prompt\')">Button</button></html>',
|
})).toContainTextContent('- button "Button" [ref=e2]');
|
||||||
},
|
|
||||||
})).toContainTextContent('- button "Button" [ref=s1e3]');
|
|
||||||
|
|
||||||
expect(await client.callTool({
|
expect(await client.callTool({
|
||||||
name: 'browser_click',
|
name: 'browser_click',
|
||||||
arguments: {
|
arguments: {
|
||||||
element: 'Button',
|
element: 'Button',
|
||||||
ref: 's1e3',
|
ref: 'e2',
|
||||||
},
|
},
|
||||||
})).toContainTextContent(`### Modal state
|
})).toContainTextContent(`### Modal state
|
||||||
- ["prompt" dialog with message "Prompt"]: can be handled by the "browser_handle_dialog" tool`);
|
- ["prompt" dialog with message "Prompt"]: can be handled by the "browser_handle_dialog" tool`);
|
||||||
@@ -187,6 +207,6 @@ test('prompt dialog', async ({ client }) => {
|
|||||||
|
|
||||||
expect(result).toContainTextContent(`- Page Snapshot
|
expect(result).toContainTextContent(`- Page Snapshot
|
||||||
\`\`\`yaml
|
\`\`\`yaml
|
||||||
- generic [ref=s2e2]: Answer
|
- generic [ref=e1]: Answer
|
||||||
\`\`\``);
|
\`\`\``);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -18,16 +18,20 @@ import { test, expect } from './fixtures.js';
|
|||||||
import fs from 'fs/promises';
|
import fs from 'fs/promises';
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
|
|
||||||
test('browser_file_upload', async ({ client }) => {
|
test('browser_file_upload', async ({ client, localOutputPath, server }) => {
|
||||||
|
server.setContent('/', `
|
||||||
|
<input type="file" />
|
||||||
|
<button>Button</button>
|
||||||
|
`, 'text/html');
|
||||||
|
|
||||||
expect(await client.callTool({
|
expect(await client.callTool({
|
||||||
name: 'browser_navigate',
|
name: 'browser_navigate',
|
||||||
arguments: {
|
arguments: { url: server.PREFIX },
|
||||||
url: 'data:text/html,<html><title>Title</title><input type="file" /><button>Button</button></html>',
|
|
||||||
},
|
|
||||||
})).toContainTextContent(`
|
})).toContainTextContent(`
|
||||||
\`\`\`yaml
|
\`\`\`yaml
|
||||||
- button "Choose File" [ref=s1e3]
|
- generic [ref=e1]:
|
||||||
- button "Button" [ref=s1e4]
|
- button "Choose File" [ref=e2]
|
||||||
|
- button "Button" [ref=e3]
|
||||||
\`\`\``);
|
\`\`\``);
|
||||||
|
|
||||||
{
|
{
|
||||||
@@ -45,12 +49,12 @@ The tool "browser_file_upload" can only be used when there is related modal stat
|
|||||||
name: 'browser_click',
|
name: 'browser_click',
|
||||||
arguments: {
|
arguments: {
|
||||||
element: 'Textbox',
|
element: 'Textbox',
|
||||||
ref: 's1e3',
|
ref: 'e2',
|
||||||
},
|
},
|
||||||
})).toContainTextContent(`### Modal state
|
})).toContainTextContent(`### Modal state
|
||||||
- [File chooser]: can be handled by the "browser_file_upload" tool`);
|
- [File chooser]: can be handled by the "browser_file_upload" tool`);
|
||||||
|
|
||||||
const filePath = test.info().outputPath('test.txt');
|
const filePath = localOutputPath('test.txt');
|
||||||
await fs.writeFile(filePath, 'Hello, world!');
|
await fs.writeFile(filePath, 'Hello, world!');
|
||||||
|
|
||||||
{
|
{
|
||||||
@@ -64,8 +68,9 @@ The tool "browser_file_upload" can only be used when there is related modal stat
|
|||||||
expect(response).not.toContainTextContent('### Modal state');
|
expect(response).not.toContainTextContent('### Modal state');
|
||||||
expect(response).toContainTextContent(`
|
expect(response).toContainTextContent(`
|
||||||
\`\`\`yaml
|
\`\`\`yaml
|
||||||
- button "Choose File" [ref=s3e3]
|
- generic [ref=e1]:
|
||||||
- button "Button" [ref=s3e4]
|
- button "Choose File" [ref=e2]
|
||||||
|
- button "Button" [ref=e3]
|
||||||
\`\`\``);
|
\`\`\``);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -74,7 +79,7 @@ The tool "browser_file_upload" can only be used when there is related modal stat
|
|||||||
name: 'browser_click',
|
name: 'browser_click',
|
||||||
arguments: {
|
arguments: {
|
||||||
element: 'Textbox',
|
element: 'Textbox',
|
||||||
ref: 's3e3',
|
ref: 'e2',
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -86,7 +91,7 @@ The tool "browser_file_upload" can only be used when there is related modal stat
|
|||||||
name: 'browser_click',
|
name: 'browser_click',
|
||||||
arguments: {
|
arguments: {
|
||||||
element: 'Button',
|
element: 'Button',
|
||||||
ref: 's4e4',
|
ref: 'e3',
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -96,26 +101,45 @@ The tool "browser_file_upload" can only be used when there is related modal stat
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
test('clicking on download link emits download', async ({ startClient }, testInfo) => {
|
test('clicking on download link emits download', async ({ startClient, localOutputPath, server }) => {
|
||||||
const outputDir = testInfo.outputPath('output');
|
const outputDir = localOutputPath('output');
|
||||||
const client = await startClient({
|
const client = await startClient({
|
||||||
config: { outputDir },
|
config: { outputDir },
|
||||||
});
|
});
|
||||||
|
|
||||||
|
server.setContent('/', `<a href="/download" download="test.txt">Download</a>`, 'text/html');
|
||||||
|
server.setContent('/download', 'Data', 'text/plain');
|
||||||
|
|
||||||
|
expect(await client.callTool({
|
||||||
|
name: 'browser_navigate',
|
||||||
|
arguments: { url: server.PREFIX },
|
||||||
|
})).toContainTextContent('- link "Download" [ref=e2]');
|
||||||
|
await client.callTool({
|
||||||
|
name: 'browser_click',
|
||||||
|
arguments: {
|
||||||
|
element: 'Download link',
|
||||||
|
ref: 'e2',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await expect.poll(() => client.callTool({ name: 'browser_snapshot' })).toContainTextContent(`
|
||||||
|
### Downloads
|
||||||
|
- Downloaded file test.txt to ${path.join(outputDir, 'test.txt')}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('navigating to download link emits download', async ({ client, server, mcpBrowser }) => {
|
||||||
|
test.skip(mcpBrowser === 'webkit' && process.platform === 'linux', 'https://github.com/microsoft/playwright/blob/8e08fdb52c27bb75de9bf87627bf740fadab2122/tests/library/download.spec.ts#L436');
|
||||||
|
server.route('/download', (req, res) => {
|
||||||
|
res.writeHead(200, {
|
||||||
|
'Content-Type': 'text/plain',
|
||||||
|
'Content-Disposition': 'attachment; filename=test.txt',
|
||||||
|
});
|
||||||
|
res.end('Hello world!');
|
||||||
|
});
|
||||||
|
|
||||||
expect(await client.callTool({
|
expect(await client.callTool({
|
||||||
name: 'browser_navigate',
|
name: 'browser_navigate',
|
||||||
arguments: {
|
arguments: {
|
||||||
url: 'data:text/html,<a href="data:text/plain,Hello world!" download="test.txt">Download</a>',
|
url: server.PREFIX + 'download',
|
||||||
},
|
},
|
||||||
})).toContainTextContent('- link "Download" [ref=s1e3]');
|
})).toContainTextContent('### Downloads');
|
||||||
await client.callTool({
|
|
||||||
name: 'browser_click',
|
|
||||||
arguments: {
|
|
||||||
element: 'Download link',
|
|
||||||
ref: 's1e3',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
await expect.poll(() => client.callTool({ name: 'browser_snapshot', arguments: {} })).toContainTextContent(`
|
|
||||||
### Downloads
|
|
||||||
- Downloaded file test.txt to ${path.join(outputDir, 'test.txt')}`);
|
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ import type { Config } from '../config';
|
|||||||
|
|
||||||
export type TestOptions = {
|
export type TestOptions = {
|
||||||
mcpBrowser: string | undefined;
|
mcpBrowser: string | undefined;
|
||||||
|
mcpMode: 'docker' | undefined;
|
||||||
};
|
};
|
||||||
|
|
||||||
type TestFixtures = {
|
type TestFixtures = {
|
||||||
@@ -40,6 +41,7 @@ type TestFixtures = {
|
|||||||
server: TestServer;
|
server: TestServer;
|
||||||
httpsServer: TestServer;
|
httpsServer: TestServer;
|
||||||
mcpHeadless: boolean;
|
mcpHeadless: boolean;
|
||||||
|
localOutputPath: (filePath: string) => string;
|
||||||
};
|
};
|
||||||
|
|
||||||
type WorkerFixtures = {
|
type WorkerFixtures = {
|
||||||
@@ -56,12 +58,13 @@ export const test = baseTest.extend<TestFixtures & TestOptions, WorkerFixtures>(
|
|||||||
await use(await startClient({ args: ['--vision'] }));
|
await use(await startClient({ args: ['--vision'] }));
|
||||||
},
|
},
|
||||||
|
|
||||||
startClient: async ({ mcpHeadless, mcpBrowser }, use, testInfo) => {
|
startClient: async ({ mcpHeadless, mcpBrowser, mcpMode }, use, testInfo) => {
|
||||||
const userDataDir = testInfo.outputPath('user-data-dir');
|
const userDataDir = testInfo.outputPath('user-data-dir');
|
||||||
|
const configDir = path.dirname(test.info().config.configFile!);
|
||||||
let client: Client | undefined;
|
let client: Client | undefined;
|
||||||
|
|
||||||
await use(async options => {
|
await use(async options => {
|
||||||
const args = ['--user-data-dir', userDataDir];
|
const args = ['--user-data-dir', path.relative(configDir, userDataDir)];
|
||||||
if (mcpHeadless)
|
if (mcpHeadless)
|
||||||
args.push('--headless');
|
args.push('--headless');
|
||||||
if (mcpBrowser)
|
if (mcpBrowser)
|
||||||
@@ -71,15 +74,11 @@ export const test = baseTest.extend<TestFixtures & TestOptions, WorkerFixtures>(
|
|||||||
if (options?.config) {
|
if (options?.config) {
|
||||||
const configFile = testInfo.outputPath('config.json');
|
const configFile = testInfo.outputPath('config.json');
|
||||||
await fs.promises.writeFile(configFile, JSON.stringify(options.config, null, 2));
|
await fs.promises.writeFile(configFile, JSON.stringify(options.config, null, 2));
|
||||||
args.push(`--config=${configFile}`);
|
args.push(`--config=${path.relative(configDir, configFile)}`);
|
||||||
}
|
}
|
||||||
// NOTE: Can be removed when we drop Node.js 18 support and changed to import.meta.filename.
|
|
||||||
const __filename = url.fileURLToPath(import.meta.url);
|
|
||||||
const transport = new StdioClientTransport({
|
|
||||||
command: 'node',
|
|
||||||
args: [path.join(path.dirname(__filename), '../cli.js'), ...args],
|
|
||||||
});
|
|
||||||
client = new Client({ name: options?.clientName ?? 'test', version: '1.0.0' });
|
client = new Client({ name: options?.clientName ?? 'test', version: '1.0.0' });
|
||||||
|
const transport = createTransport(args, mcpMode);
|
||||||
await client.connect(transport);
|
await client.connect(transport);
|
||||||
await client.ping();
|
await client.ping();
|
||||||
return client;
|
return client;
|
||||||
@@ -121,7 +120,12 @@ export const test = baseTest.extend<TestFixtures & TestOptions, WorkerFixtures>(
|
|||||||
});
|
});
|
||||||
return `http://localhost:${port}`;
|
return `http://localhost:${port}`;
|
||||||
});
|
});
|
||||||
browserProcess?.kill();
|
await new Promise<void>(resolve => {
|
||||||
|
if (!browserProcess)
|
||||||
|
return resolve();
|
||||||
|
browserProcess.on('exit', () => resolve());
|
||||||
|
browserProcess.kill();
|
||||||
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
mcpHeadless: async ({ headless }, use) => {
|
mcpHeadless: async ({ headless }, use) => {
|
||||||
@@ -130,6 +134,15 @@ export const test = baseTest.extend<TestFixtures & TestOptions, WorkerFixtures>(
|
|||||||
|
|
||||||
mcpBrowser: ['chrome', { option: true }],
|
mcpBrowser: ['chrome', { option: true }],
|
||||||
|
|
||||||
|
mcpMode: [undefined, { option: true }],
|
||||||
|
|
||||||
|
localOutputPath: async ({ mcpMode }, use, testInfo) => {
|
||||||
|
await use(filePath => {
|
||||||
|
test.skip(mcpMode === 'docker', 'Mounting files is not supported in docker mode');
|
||||||
|
return testInfo.outputPath(filePath);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
_workerServers: [async ({}, use, workerInfo) => {
|
_workerServers: [async ({}, use, workerInfo) => {
|
||||||
const port = 8907 + workerInfo.workerIndex * 4;
|
const port = 8907 + workerInfo.workerIndex * 4;
|
||||||
const server = await TestServer.create(port);
|
const server = await TestServer.create(port);
|
||||||
@@ -156,6 +169,23 @@ export const test = baseTest.extend<TestFixtures & TestOptions, WorkerFixtures>(
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
function createTransport(args: string[], mcpMode: TestOptions['mcpMode']) {
|
||||||
|
// NOTE: Can be removed when we drop Node.js 18 support and changed to import.meta.filename.
|
||||||
|
const __filename = url.fileURLToPath(import.meta.url);
|
||||||
|
if (mcpMode === 'docker') {
|
||||||
|
const dockerArgs = ['run', '--rm', '-i', '--network=host', '-v', `${test.info().project.outputDir}:/app/test-results`];
|
||||||
|
return new StdioClientTransport({
|
||||||
|
command: 'docker',
|
||||||
|
args: [...dockerArgs, 'playwright-mcp-dev:latest', ...args],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return new StdioClientTransport({
|
||||||
|
command: 'node',
|
||||||
|
args: [path.join(path.dirname(__filename), '../cli.js'), ...args],
|
||||||
|
cwd: path.join(path.dirname(__filename), '..'),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
type Response = Awaited<ReturnType<Client['callTool']>>;
|
type Response = Awaited<ReturnType<Client['callTool']>>;
|
||||||
|
|
||||||
export const expect = baseExpect.extend({
|
export const expect = baseExpect.extend({
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ for (const mcpHeadless of [false, true]) {
|
|||||||
test.describe(`mcpHeadless: ${mcpHeadless}`, () => {
|
test.describe(`mcpHeadless: ${mcpHeadless}`, () => {
|
||||||
test.use({ mcpHeadless });
|
test.use({ mcpHeadless });
|
||||||
test.skip(process.platform === 'linux', 'Auto-detection wont let this test run on linux');
|
test.skip(process.platform === 'linux', 'Auto-detection wont let this test run on linux');
|
||||||
|
test.skip(({ mcpMode, mcpHeadless }) => mcpMode === 'docker' && !mcpHeadless, 'Headed mode is not supported in docker');
|
||||||
test('browser', async ({ client, server, mcpBrowser }) => {
|
test('browser', async ({ client, server, mcpBrowser }) => {
|
||||||
test.skip(!['chrome', 'msedge', 'chromium'].includes(mcpBrowser ?? ''), 'Only chrome is supported for this test');
|
test.skip(!['chrome', 'msedge', 'chromium'].includes(mcpBrowser ?? ''), 'Only chrome is supported for this test');
|
||||||
server.route('/', (req, res) => {
|
server.route('/', (req, res) => {
|
||||||
|
|||||||
@@ -24,19 +24,21 @@ test('stitched aria frames', async ({ client }) => {
|
|||||||
},
|
},
|
||||||
})).toContainTextContent(`
|
})).toContainTextContent(`
|
||||||
\`\`\`yaml
|
\`\`\`yaml
|
||||||
- heading "Hello" [level=1] [ref=s1e3]
|
- generic [ref=e1]:
|
||||||
- iframe [ref=s1e4]:
|
- heading "Hello" [level=1] [ref=e2]
|
||||||
- button "World" [ref=f1s1e3]
|
- iframe [ref=e3]:
|
||||||
- main [ref=f1s1e4]:
|
- generic [ref=f1e1]:
|
||||||
- iframe [ref=f1s1e5]:
|
- button "World" [ref=f1e2]
|
||||||
- paragraph [ref=f2s1e3]: Nested
|
- main [ref=f1e3]:
|
||||||
|
- iframe [ref=f1e4]:
|
||||||
|
- paragraph [ref=f2e2]: Nested
|
||||||
\`\`\``);
|
\`\`\``);
|
||||||
|
|
||||||
expect(await client.callTool({
|
expect(await client.callTool({
|
||||||
name: 'browser_click',
|
name: 'browser_click',
|
||||||
arguments: {
|
arguments: {
|
||||||
element: 'World',
|
element: 'World',
|
||||||
ref: 'f1s1e3',
|
ref: 'f1e2',
|
||||||
},
|
},
|
||||||
})).toContainTextContent(`// Click World`);
|
})).toContainTextContent(`// Click World`);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -20,6 +20,5 @@ test('browser_install', async ({ client, mcpBrowser }) => {
|
|||||||
test.skip(mcpBrowser !== 'chromium', 'Test only chromium');
|
test.skip(mcpBrowser !== 'chromium', 'Test only chromium');
|
||||||
expect(await client.callTool({
|
expect(await client.callTool({
|
||||||
name: 'browser_install',
|
name: 'browser_install',
|
||||||
arguments: {},
|
|
||||||
})).toContainTextContent(`No open pages available.`);
|
})).toContainTextContent(`No open pages available.`);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -16,34 +16,27 @@
|
|||||||
|
|
||||||
import { test, expect } from './fixtures.js';
|
import { test, expect } from './fixtures.js';
|
||||||
|
|
||||||
test('test reopen browser', async ({ client }) => {
|
test('test reopen browser', async ({ client, server }) => {
|
||||||
await client.callTool({
|
await client.callTool({
|
||||||
name: 'browser_navigate',
|
name: 'browser_navigate',
|
||||||
arguments: {
|
arguments: { url: server.HELLO_WORLD },
|
||||||
url: 'data:text/html,<html><title>Title</title><body>Hello, world!</body></html>',
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(await client.callTool({
|
expect(await client.callTool({
|
||||||
name: 'browser_close',
|
name: 'browser_close',
|
||||||
arguments: {},
|
|
||||||
})).toContainTextContent('No open pages available');
|
})).toContainTextContent('No open pages available');
|
||||||
|
|
||||||
expect(await client.callTool({
|
expect(await client.callTool({
|
||||||
name: 'browser_navigate',
|
name: 'browser_navigate',
|
||||||
arguments: {
|
arguments: { url: server.HELLO_WORLD },
|
||||||
url: 'data:text/html,<html><title>Title</title><body>Hello, world!</body></html>',
|
})).toContainTextContent(`- generic [ref=e1]: Hello, world!`);
|
||||||
},
|
|
||||||
})).toContainTextContent(`- generic [ref=s1e2]: Hello, world!`);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test('executable path', async ({ startClient }) => {
|
test('executable path', async ({ startClient, server }) => {
|
||||||
const client = await startClient({ args: [`--executable-path=bogus`] });
|
const client = await startClient({ args: [`--executable-path=bogus`] });
|
||||||
const response = await client.callTool({
|
const response = await client.callTool({
|
||||||
name: 'browser_navigate',
|
name: 'browser_navigate',
|
||||||
arguments: {
|
arguments: { url: server.HELLO_WORLD },
|
||||||
url: 'data:text/html,<html><title>Title</title><body>Hello, world!</body></html>',
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
expect(response).toContainTextContent(`executable doesn't exist`);
|
expect(response).toContainTextContent(`executable doesn't exist`);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -17,15 +17,11 @@
|
|||||||
import { test, expect } from './fixtures.js';
|
import { test, expect } from './fixtures.js';
|
||||||
|
|
||||||
test('browser_network_requests', async ({ client, server }) => {
|
test('browser_network_requests', async ({ client, server }) => {
|
||||||
server.route('/', (req, res) => {
|
server.setContent('/', `
|
||||||
res.writeHead(200, { 'Content-Type': 'text/html' });
|
<button onclick="fetch('/json')">Click me</button>
|
||||||
res.end(`<button onclick="fetch('/json')">Click me</button>`);
|
`, 'text/html');
|
||||||
});
|
|
||||||
|
|
||||||
server.route('/json', (req, res) => {
|
server.setContent('/json', JSON.stringify({ name: 'John Doe' }), 'application/json');
|
||||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
||||||
res.end(JSON.stringify({ name: 'John Doe' }));
|
|
||||||
});
|
|
||||||
|
|
||||||
await client.callTool({
|
await client.callTool({
|
||||||
name: 'browser_navigate',
|
name: 'browser_navigate',
|
||||||
@@ -38,12 +34,12 @@ test('browser_network_requests', async ({ client, server }) => {
|
|||||||
name: 'browser_click',
|
name: 'browser_click',
|
||||||
arguments: {
|
arguments: {
|
||||||
element: 'Click me button',
|
element: 'Click me button',
|
||||||
ref: 's1e3',
|
ref: 'e2',
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
await expect.poll(() => client.callTool({
|
await expect.poll(() => client.callTool({
|
||||||
name: 'browser_network_requests',
|
name: 'browser_network_requests',
|
||||||
arguments: {},
|
})).toHaveTextContent(`[GET] ${`${server.PREFIX}`} => [200] OK
|
||||||
})).toHaveTextContent(`[GET] http://localhost:${server.PORT}/json => [200] OK`);
|
[GET] ${`${server.PREFIX}json`} => [200] OK`);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -14,15 +14,15 @@
|
|||||||
* limitations under the License.
|
* limitations under the License.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import fs from 'fs';
|
||||||
|
|
||||||
import { test, expect } from './fixtures.js';
|
import { test, expect } from './fixtures.js';
|
||||||
|
|
||||||
test('save as pdf unavailable', async ({ startClient }) => {
|
test('save as pdf unavailable', async ({ startClient, server }) => {
|
||||||
const client = await startClient({ args: ['--caps="no-pdf"'] });
|
const client = await startClient({ args: ['--caps="no-pdf"'] });
|
||||||
await client.callTool({
|
await client.callTool({
|
||||||
name: 'browser_navigate',
|
name: 'browser_navigate',
|
||||||
arguments: {
|
arguments: { url: server.HELLO_WORLD },
|
||||||
url: 'data:text/html,<html><title>Title</title><body>Hello, world!</body></html>',
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(await client.callTool({
|
expect(await client.callTool({
|
||||||
@@ -30,18 +30,49 @@ test('save as pdf unavailable', async ({ startClient }) => {
|
|||||||
})).toHaveTextContent(/Tool \"browser_pdf_save\" not found/);
|
})).toHaveTextContent(/Tool \"browser_pdf_save\" not found/);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('save as pdf', async ({ client, mcpBrowser }) => {
|
test('save as pdf', async ({ client, mcpBrowser, server }) => {
|
||||||
test.skip(!!mcpBrowser && !['chromium', 'chrome', 'msedge'].includes(mcpBrowser), 'Save as PDF is only supported in Chromium.');
|
test.skip(!!mcpBrowser && !['chromium', 'chrome', 'msedge'].includes(mcpBrowser), 'Save as PDF is only supported in Chromium.');
|
||||||
|
|
||||||
expect(await client.callTool({
|
expect(await client.callTool({
|
||||||
name: 'browser_navigate',
|
name: 'browser_navigate',
|
||||||
arguments: {
|
arguments: { url: server.HELLO_WORLD },
|
||||||
url: 'data:text/html,<html><title>Title</title><body>Hello, world!</body></html>',
|
})).toContainTextContent(`- generic [ref=e1]: Hello, world!`);
|
||||||
},
|
|
||||||
})).toContainTextContent(`- generic [ref=s1e2]: Hello, world!`);
|
|
||||||
|
|
||||||
const response = await client.callTool({
|
const response = await client.callTool({
|
||||||
name: 'browser_pdf_save',
|
name: 'browser_pdf_save',
|
||||||
arguments: {},
|
|
||||||
});
|
});
|
||||||
expect(response).toHaveTextContent(/Save page as.*page-[^:]+.pdf/);
|
expect(response).toHaveTextContent(/Save page as.*page-[^:]+.pdf/);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('save as pdf (filename: output.pdf)', async ({ startClient, mcpBrowser, server, localOutputPath }) => {
|
||||||
|
test.skip(!!mcpBrowser && !['chromium', 'chrome', 'msedge'].includes(mcpBrowser), 'Save as PDF is only supported in Chromium.');
|
||||||
|
const outputDir = localOutputPath('output');
|
||||||
|
const client = await startClient({
|
||||||
|
config: { outputDir },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(await client.callTool({
|
||||||
|
name: 'browser_navigate',
|
||||||
|
arguments: { url: server.HELLO_WORLD },
|
||||||
|
})).toContainTextContent(`- generic [ref=e1]: Hello, world!`);
|
||||||
|
|
||||||
|
expect(await client.callTool({
|
||||||
|
name: 'browser_pdf_save',
|
||||||
|
arguments: {
|
||||||
|
filename: 'output.pdf',
|
||||||
|
},
|
||||||
|
})).toEqual({
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: 'text',
|
||||||
|
text: expect.stringContaining(`output.pdf`),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
const files = [...fs.readdirSync(outputDir)];
|
||||||
|
|
||||||
|
expect(fs.existsSync(outputDir)).toBeTruthy();
|
||||||
|
expect(files).toHaveLength(1);
|
||||||
|
expect(files[0]).toMatch(/^output.pdf$/);
|
||||||
|
});
|
||||||
|
|||||||
@@ -31,11 +31,8 @@ const fetchPage = async (client: Client, url: string) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
test('default to allow all', async ({ server, client }) => {
|
test('default to allow all', async ({ server, client }) => {
|
||||||
server.route('/ppp', (_req, res) => {
|
server.setContent('/ppp', 'content:PPP', 'text/html');
|
||||||
res.writeHead(200, { 'Content-Type': 'text/html' });
|
const result = await fetchPage(client, server.PREFIX + 'ppp');
|
||||||
res.end('content:PPP');
|
|
||||||
});
|
|
||||||
const result = await fetchPage(client, server.PREFIX + '/ppp');
|
|
||||||
expect(result).toContain('content:PPP');
|
expect(result).toContain('content:PPP');
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -48,14 +45,11 @@ test('blocked works', async ({ startClient }) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test('allowed works', async ({ server, startClient }) => {
|
test('allowed works', async ({ server, startClient }) => {
|
||||||
server.route('/ppp', (_req, res) => {
|
server.setContent('/ppp', 'content:PPP', 'text/html');
|
||||||
res.writeHead(200, { 'Content-Type': 'text/html' });
|
|
||||||
res.end('content:PPP');
|
|
||||||
});
|
|
||||||
const client = await startClient({
|
const client = await startClient({
|
||||||
args: ['--allowed-origins', `microsoft.com;${new URL(server.PREFIX).host};playwright.dev`]
|
args: ['--allowed-origins', `microsoft.com;${new URL(server.PREFIX).host};playwright.dev`]
|
||||||
});
|
});
|
||||||
const result = await fetchPage(client, server.PREFIX + '/ppp');
|
const result = await fetchPage(client, server.PREFIX + 'ppp');
|
||||||
expect(result).toContain('content:PPP');
|
expect(result).toContain('content:PPP');
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -79,13 +73,10 @@ test('allowed without blocked blocks all non-explicitly specified origins', asyn
|
|||||||
});
|
});
|
||||||
|
|
||||||
test('blocked without allowed allows non-explicitly specified origins', async ({ server, startClient }) => {
|
test('blocked without allowed allows non-explicitly specified origins', async ({ server, startClient }) => {
|
||||||
server.route('/ppp', (_req, res) => {
|
server.setContent('/ppp', 'content:PPP', 'text/html');
|
||||||
res.writeHead(200, { 'Content-Type': 'text/html' });
|
|
||||||
res.end('content:PPP');
|
|
||||||
});
|
|
||||||
const client = await startClient({
|
const client = await startClient({
|
||||||
args: ['--blocked-origins', 'example.com'],
|
args: ['--blocked-origins', 'example.com'],
|
||||||
});
|
});
|
||||||
const result = await fetchPage(client, server.PREFIX + '/ppp');
|
const result = await fetchPage(client, server.PREFIX + 'ppp');
|
||||||
expect(result).toContain('content:PPP');
|
expect(result).toContain('content:PPP');
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -18,17 +18,14 @@ import fs from 'fs';
|
|||||||
|
|
||||||
import { test, expect } from './fixtures.js';
|
import { test, expect } from './fixtures.js';
|
||||||
|
|
||||||
test('browser_take_screenshot (viewport)', async ({ client }) => {
|
test('browser_take_screenshot (viewport)', async ({ client, server }) => {
|
||||||
expect(await client.callTool({
|
expect(await client.callTool({
|
||||||
name: 'browser_navigate',
|
name: 'browser_navigate',
|
||||||
arguments: {
|
arguments: { url: server.HELLO_WORLD },
|
||||||
url: 'data:text/html,<html><title>Title</title><body>Hello, world!</body></html>',
|
})).toContainTextContent(`Navigate to http://localhost`);
|
||||||
},
|
|
||||||
})).toContainTextContent(`Navigate to data:text/html`);
|
|
||||||
|
|
||||||
expect(await client.callTool({
|
expect(await client.callTool({
|
||||||
name: 'browser_take_screenshot',
|
name: 'browser_take_screenshot',
|
||||||
arguments: {},
|
|
||||||
})).toEqual({
|
})).toEqual({
|
||||||
content: [
|
content: [
|
||||||
{
|
{
|
||||||
@@ -44,19 +41,17 @@ test('browser_take_screenshot (viewport)', async ({ client }) => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
test('browser_take_screenshot (element)', async ({ client }) => {
|
test('browser_take_screenshot (element)', async ({ client, server }) => {
|
||||||
expect(await client.callTool({
|
expect(await client.callTool({
|
||||||
name: 'browser_navigate',
|
name: 'browser_navigate',
|
||||||
arguments: {
|
arguments: { url: server.HELLO_WORLD },
|
||||||
url: 'data:text/html,<html><title>Title</title><button>Hello, world!</button></html>',
|
})).toContainTextContent(`[ref=e1]`);
|
||||||
},
|
|
||||||
})).toContainTextContent(`[ref=s1e3]`);
|
|
||||||
|
|
||||||
expect(await client.callTool({
|
expect(await client.callTool({
|
||||||
name: 'browser_take_screenshot',
|
name: 'browser_take_screenshot',
|
||||||
arguments: {
|
arguments: {
|
||||||
element: 'hello button',
|
element: 'hello button',
|
||||||
ref: 's1e3',
|
ref: 'e1',
|
||||||
},
|
},
|
||||||
})).toEqual({
|
})).toEqual({
|
||||||
content: [
|
content: [
|
||||||
@@ -66,57 +61,110 @@ test('browser_take_screenshot (element)', async ({ client }) => {
|
|||||||
type: 'image',
|
type: 'image',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
text: expect.stringContaining(`page.getByRole('button', { name: 'Hello, world!' }).screenshot`),
|
text: expect.stringContaining(`page.getByText('Hello, world!').screenshot`),
|
||||||
type: 'text',
|
type: 'text',
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
test('--output-dir should work', async ({ startClient }, testInfo) => {
|
test('--output-dir should work', async ({ startClient, localOutputPath, server }) => {
|
||||||
const outputDir = testInfo.outputPath('output');
|
const outputDir = localOutputPath('output');
|
||||||
const client = await startClient({
|
const client = await startClient({
|
||||||
args: ['--output-dir', outputDir],
|
args: ['--output-dir', outputDir],
|
||||||
});
|
});
|
||||||
expect(await client.callTool({
|
expect(await client.callTool({
|
||||||
name: 'browser_navigate',
|
name: 'browser_navigate',
|
||||||
arguments: {
|
arguments: { url: server.HELLO_WORLD },
|
||||||
url: 'data:text/html,<html><title>Title</title><body>Hello, world!</body></html>',
|
})).toContainTextContent(`Navigate to http://localhost`);
|
||||||
},
|
|
||||||
})).toContainTextContent(`Navigate to data:text/html`);
|
|
||||||
|
|
||||||
await client.callTool({
|
await client.callTool({
|
||||||
name: 'browser_take_screenshot',
|
name: 'browser_take_screenshot',
|
||||||
arguments: {},
|
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(fs.existsSync(outputDir)).toBeTruthy();
|
expect(fs.existsSync(outputDir)).toBeTruthy();
|
||||||
expect([...fs.readdirSync(outputDir)]).toHaveLength(1);
|
expect([...fs.readdirSync(outputDir)]).toHaveLength(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
for (const raw of [undefined, true]) {
|
||||||
|
test(`browser_take_screenshot (raw: ${raw})`, async ({ startClient, localOutputPath, server }) => {
|
||||||
|
const ext = raw ? 'png' : 'jpeg';
|
||||||
|
const outputDir = localOutputPath('output');
|
||||||
|
const client = await startClient({
|
||||||
|
config: { outputDir },
|
||||||
|
});
|
||||||
|
expect(await client.callTool({
|
||||||
|
name: 'browser_navigate',
|
||||||
|
arguments: { url: server.PREFIX },
|
||||||
|
})).toContainTextContent(`Navigate to http://localhost`);
|
||||||
|
|
||||||
test('browser_take_screenshot (outputDir)', async ({ startClient }, testInfo) => {
|
expect(await client.callTool({
|
||||||
const outputDir = testInfo.outputPath('output');
|
name: 'browser_take_screenshot',
|
||||||
|
arguments: { raw },
|
||||||
|
})).toEqual({
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
data: expect.any(String),
|
||||||
|
mimeType: `image/${ext}`,
|
||||||
|
type: 'image',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
text: expect.stringMatching(
|
||||||
|
new RegExp(`page-\\d{4}-\\d{2}-\\d{2}T\\d{2}-\\d{2}-\\d{2}\\-\\d{3}Z\\.${ext}`)
|
||||||
|
),
|
||||||
|
type: 'text',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
const files = [...fs.readdirSync(outputDir)];
|
||||||
|
|
||||||
|
expect(fs.existsSync(outputDir)).toBeTruthy();
|
||||||
|
expect(files).toHaveLength(1);
|
||||||
|
expect(files[0]).toMatch(
|
||||||
|
new RegExp(`^page-\\d{4}-\\d{2}-\\d{2}T\\d{2}-\\d{2}-\\d{2}-\\d{3}Z\\.${ext}$`)
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
test('browser_take_screenshot (filename: "output.jpeg")', async ({ startClient, localOutputPath, server }) => {
|
||||||
|
const outputDir = localOutputPath('output');
|
||||||
const client = await startClient({
|
const client = await startClient({
|
||||||
config: { outputDir },
|
config: { outputDir },
|
||||||
});
|
});
|
||||||
expect(await client.callTool({
|
expect(await client.callTool({
|
||||||
name: 'browser_navigate',
|
name: 'browser_navigate',
|
||||||
arguments: {
|
arguments: { url: server.HELLO_WORLD },
|
||||||
url: 'data:text/html,<html><title>Title</title><body>Hello, world!</body></html>',
|
})).toContainTextContent(`Navigate to http://localhost`);
|
||||||
},
|
|
||||||
})).toContainTextContent(`Navigate to data:text/html`);
|
|
||||||
|
|
||||||
await client.callTool({
|
expect(await client.callTool({
|
||||||
name: 'browser_take_screenshot',
|
name: 'browser_take_screenshot',
|
||||||
arguments: {},
|
arguments: {
|
||||||
|
filename: 'output.jpeg',
|
||||||
|
},
|
||||||
|
})).toEqual({
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
data: expect.any(String),
|
||||||
|
mimeType: 'image/jpeg',
|
||||||
|
type: 'image',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
text: expect.stringContaining(`output.jpeg`),
|
||||||
|
type: 'text',
|
||||||
|
},
|
||||||
|
],
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const files = [...fs.readdirSync(outputDir)];
|
||||||
|
|
||||||
expect(fs.existsSync(outputDir)).toBeTruthy();
|
expect(fs.existsSync(outputDir)).toBeTruthy();
|
||||||
expect([...fs.readdirSync(outputDir)]).toHaveLength(1);
|
expect(files).toHaveLength(1);
|
||||||
|
expect(files[0]).toMatch(/^output.jpeg$/);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('browser_take_screenshot (noImageResponses)', async ({ startClient }) => {
|
test('browser_take_screenshot (noImageResponses)', async ({ startClient, server }) => {
|
||||||
const client = await startClient({
|
const client = await startClient({
|
||||||
config: {
|
config: {
|
||||||
noImageResponses: true,
|
noImageResponses: true,
|
||||||
@@ -125,19 +173,15 @@ test('browser_take_screenshot (noImageResponses)', async ({ startClient }) => {
|
|||||||
|
|
||||||
expect(await client.callTool({
|
expect(await client.callTool({
|
||||||
name: 'browser_navigate',
|
name: 'browser_navigate',
|
||||||
arguments: {
|
arguments: { url: server.HELLO_WORLD },
|
||||||
url: 'data:text/html,<html><title>Title</title><body>Hello, world!</body></html>',
|
})).toContainTextContent(`Navigate to http://localhost`);
|
||||||
},
|
|
||||||
})).toContainTextContent(`Navigate to data:text/html`);
|
|
||||||
|
|
||||||
await client.callTool({
|
await client.callTool({
|
||||||
name: 'browser_take_screenshot',
|
name: 'browser_take_screenshot',
|
||||||
arguments: {},
|
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(await client.callTool({
|
expect(await client.callTool({
|
||||||
name: 'browser_take_screenshot',
|
name: 'browser_take_screenshot',
|
||||||
arguments: {},
|
|
||||||
})).toEqual({
|
})).toEqual({
|
||||||
content: [
|
content: [
|
||||||
{
|
{
|
||||||
@@ -148,24 +192,20 @@ test('browser_take_screenshot (noImageResponses)', async ({ startClient }) => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
test('browser_take_screenshot (cursor)', async ({ startClient }) => {
|
test('browser_take_screenshot (cursor)', async ({ startClient, server }) => {
|
||||||
const client = await startClient({ clientName: 'cursor:vscode' });
|
const client = await startClient({ clientName: 'cursor:vscode' });
|
||||||
|
|
||||||
expect(await client.callTool({
|
expect(await client.callTool({
|
||||||
name: 'browser_navigate',
|
name: 'browser_navigate',
|
||||||
arguments: {
|
arguments: { url: server.HELLO_WORLD },
|
||||||
url: 'data:text/html,<html><title>Title</title><body>Hello, world!</body></html>',
|
})).toContainTextContent(`Navigate to http://localhost`);
|
||||||
},
|
|
||||||
})).toContainTextContent(`Navigate to data:text/html`);
|
|
||||||
|
|
||||||
await client.callTool({
|
await client.callTool({
|
||||||
name: 'browser_take_screenshot',
|
name: 'browser_take_screenshot',
|
||||||
arguments: {},
|
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(await client.callTool({
|
expect(await client.callTool({
|
||||||
name: 'browser_take_screenshot',
|
name: 'browser_take_screenshot',
|
||||||
arguments: {},
|
|
||||||
})).toEqual({
|
})).toEqual({
|
||||||
content: [
|
content: [
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -15,10 +15,18 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import url from 'node:url';
|
import url from 'node:url';
|
||||||
|
import http from 'node:http';
|
||||||
import { spawn } from 'node:child_process';
|
import { spawn } from 'node:child_process';
|
||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
import { test as baseTest } from './fixtures.js';
|
import type { AddressInfo } from 'node:net';
|
||||||
import { expect } from 'playwright/test';
|
import { SSEClientTransport } from '@modelcontextprotocol/sdk/client/sse.js';
|
||||||
|
import { SSEServerTransport } from '@modelcontextprotocol/sdk/server/sse.js';
|
||||||
|
import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js';
|
||||||
|
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
|
||||||
|
|
||||||
|
import { createConnection } from '@playwright/mcp';
|
||||||
|
|
||||||
|
import { test as baseTest, expect } from './fixtures.js';
|
||||||
|
|
||||||
// NOTE: Can be removed when we drop Node.js 18 support and changed to import.meta.filename.
|
// NOTE: Can be removed when we drop Node.js 18 support and changed to import.meta.filename.
|
||||||
const __filename = url.fileURLToPath(import.meta.url);
|
const __filename = url.fileURLToPath(import.meta.url);
|
||||||
@@ -43,9 +51,6 @@ const test = baseTest.extend<{ serverEndpoint: string }>({
|
|||||||
});
|
});
|
||||||
|
|
||||||
test('sse transport', async ({ serverEndpoint }) => {
|
test('sse transport', async ({ serverEndpoint }) => {
|
||||||
// need dynamic import b/c of some ESM nonsense
|
|
||||||
const { SSEClientTransport } = await import('@modelcontextprotocol/sdk/client/sse.js');
|
|
||||||
const { Client } = await import('@modelcontextprotocol/sdk/client/index.js');
|
|
||||||
const transport = new SSEClientTransport(new URL(serverEndpoint));
|
const transport = new SSEClientTransport(new URL(serverEndpoint));
|
||||||
const client = new Client({ name: 'test', version: '1.0.0' });
|
const client = new Client({ name: 'test', version: '1.0.0' });
|
||||||
await client.connect(transport);
|
await client.connect(transport);
|
||||||
@@ -53,12 +58,46 @@ test('sse transport', async ({ serverEndpoint }) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test('streamable http transport', async ({ serverEndpoint }) => {
|
test('streamable http transport', async ({ serverEndpoint }) => {
|
||||||
// need dynamic import b/c of some ESM nonsense
|
|
||||||
const { StreamableHTTPClientTransport } = await import('@modelcontextprotocol/sdk/client/streamableHttp.js');
|
|
||||||
const { Client } = await import('@modelcontextprotocol/sdk/client/index.js');
|
|
||||||
const transport = new StreamableHTTPClientTransport(new URL('/mcp', serverEndpoint));
|
const transport = new StreamableHTTPClientTransport(new URL('/mcp', serverEndpoint));
|
||||||
const client = new Client({ name: 'test', version: '1.0.0' });
|
const client = new Client({ name: 'test', version: '1.0.0' });
|
||||||
await client.connect(transport);
|
await client.connect(transport);
|
||||||
await client.ping();
|
await client.ping();
|
||||||
expect(transport.sessionId, 'has session support').toBeDefined();
|
expect(transport.sessionId, 'has session support').toBeDefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('sse transport via public API', async ({ server }) => {
|
||||||
|
const sessions = new Map<string, SSEServerTransport>();
|
||||||
|
const mcpServer = http.createServer(async (req, res) => {
|
||||||
|
if (req.method === 'GET') {
|
||||||
|
const connection = await createConnection({ browser: { launchOptions: { headless: true } } });
|
||||||
|
const transport = new SSEServerTransport('/sse', res);
|
||||||
|
sessions.set(transport.sessionId, transport);
|
||||||
|
await connection.connect(transport);
|
||||||
|
} else if (req.method === 'POST') {
|
||||||
|
const url = new URL(`http://localhost${req.url}`);
|
||||||
|
const sessionId = url.searchParams.get('sessionId');
|
||||||
|
if (!sessionId) {
|
||||||
|
res.statusCode = 400;
|
||||||
|
return res.end('Missing sessionId');
|
||||||
|
}
|
||||||
|
const transport = sessions.get(sessionId);
|
||||||
|
if (!transport) {
|
||||||
|
res.statusCode = 404;
|
||||||
|
return res.end('Session not found');
|
||||||
|
}
|
||||||
|
void transport.handlePostMessage(req, res);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
await new Promise<void>(resolve => mcpServer.listen(0, () => resolve()));
|
||||||
|
const serverUrl = `http://localhost:${(mcpServer.address() as AddressInfo).port}/sse`;
|
||||||
|
const transport = new SSEClientTransport(new URL(serverUrl));
|
||||||
|
const client = new Client({ name: 'test', version: '1.0.0' });
|
||||||
|
await client.connect(transport);
|
||||||
|
await client.ping();
|
||||||
|
expect(await client.callTool({
|
||||||
|
name: 'browser_navigate',
|
||||||
|
arguments: { url: server.HELLO_WORLD },
|
||||||
|
})).toContainTextContent(`- generic [ref=e1]: Hello, world!`);
|
||||||
|
await client.close();
|
||||||
|
mcpServer.close();
|
||||||
|
});
|
||||||
|
|||||||
@@ -32,7 +32,6 @@ async function createTab(client: Client, title: string, body: string) {
|
|||||||
test('list initial tabs', async ({ client }) => {
|
test('list initial tabs', async ({ client }) => {
|
||||||
expect(await client.callTool({
|
expect(await client.callTool({
|
||||||
name: 'browser_tab_list',
|
name: 'browser_tab_list',
|
||||||
arguments: {},
|
|
||||||
})).toHaveTextContent(`### Open tabs
|
})).toHaveTextContent(`### Open tabs
|
||||||
- 1: (current) [] (about:blank)`);
|
- 1: (current) [] (about:blank)`);
|
||||||
});
|
});
|
||||||
@@ -41,7 +40,6 @@ test('list first tab', async ({ client }) => {
|
|||||||
await createTab(client, 'Tab one', 'Body one');
|
await createTab(client, 'Tab one', 'Body one');
|
||||||
expect(await client.callTool({
|
expect(await client.callTool({
|
||||||
name: 'browser_tab_list',
|
name: 'browser_tab_list',
|
||||||
arguments: {},
|
|
||||||
})).toHaveTextContent(`### Open tabs
|
})).toHaveTextContent(`### Open tabs
|
||||||
- 1: [] (about:blank)
|
- 1: [] (about:blank)
|
||||||
- 2: (current) [Tab one] (data:text/html,<title>Tab one</title><body>Body one</body>)`);
|
- 2: (current) [Tab one] (data:text/html,<title>Tab one</title><body>Body one</body>)`);
|
||||||
@@ -63,7 +61,7 @@ test('create new tab', async ({ client }) => {
|
|||||||
- Page Title: Tab one
|
- Page Title: Tab one
|
||||||
- Page Snapshot
|
- Page Snapshot
|
||||||
\`\`\`yaml
|
\`\`\`yaml
|
||||||
- generic [ref=s1e2]: Body one
|
- generic [ref=e1]: Body one
|
||||||
\`\`\``);
|
\`\`\``);
|
||||||
|
|
||||||
expect(await createTab(client, 'Tab two', 'Body two')).toHaveTextContent(`
|
expect(await createTab(client, 'Tab two', 'Body two')).toHaveTextContent(`
|
||||||
@@ -82,7 +80,7 @@ test('create new tab', async ({ client }) => {
|
|||||||
- Page Title: Tab two
|
- Page Title: Tab two
|
||||||
- Page Snapshot
|
- Page Snapshot
|
||||||
\`\`\`yaml
|
\`\`\`yaml
|
||||||
- generic [ref=s1e2]: Body two
|
- generic [ref=e1]: Body two
|
||||||
\`\`\``);
|
\`\`\``);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -110,7 +108,7 @@ test('select tab', async ({ client }) => {
|
|||||||
- Page Title: Tab one
|
- Page Title: Tab one
|
||||||
- Page Snapshot
|
- Page Snapshot
|
||||||
\`\`\`yaml
|
\`\`\`yaml
|
||||||
- generic [ref=s2e2]: Body one
|
- generic [ref=e1]: Body one
|
||||||
\`\`\``);
|
\`\`\``);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -137,11 +135,13 @@ test('close tab', async ({ client }) => {
|
|||||||
- Page Title: Tab one
|
- Page Title: Tab one
|
||||||
- Page Snapshot
|
- Page Snapshot
|
||||||
\`\`\`yaml
|
\`\`\`yaml
|
||||||
- generic [ref=s2e2]: Body one
|
- generic [ref=e1]: Body one
|
||||||
\`\`\``);
|
\`\`\``);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('reuse first tab when navigating', async ({ startClient, cdpEndpoint }) => {
|
test('reuse first tab when navigating', async ({ startClient, cdpEndpoint, server }) => {
|
||||||
|
server.setContent('/', `<title>Title</title><body>Body</body>`, 'text/html');
|
||||||
|
|
||||||
const browser = await chromium.connectOverCDP(await cdpEndpoint());
|
const browser = await chromium.connectOverCDP(await cdpEndpoint());
|
||||||
const [context] = browser.contexts();
|
const [context] = browser.contexts();
|
||||||
const pages = context.pages();
|
const pages = context.pages();
|
||||||
@@ -149,9 +149,7 @@ test('reuse first tab when navigating', async ({ startClient, cdpEndpoint }) =>
|
|||||||
const client = await startClient({ args: [`--cdp-endpoint=${await cdpEndpoint()}`] });
|
const client = await startClient({ args: [`--cdp-endpoint=${await cdpEndpoint()}`] });
|
||||||
await client.callTool({
|
await client.callTool({
|
||||||
name: 'browser_navigate',
|
name: 'browser_navigate',
|
||||||
arguments: {
|
arguments: { url: server.PREFIX },
|
||||||
url: 'data:text/html,<title>Title</title><body>Body</body>',
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(pages.length).toBe(1);
|
expect(pages.length).toBe(1);
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ export class TestServer {
|
|||||||
readonly PORT: number;
|
readonly PORT: number;
|
||||||
readonly PREFIX: string;
|
readonly PREFIX: string;
|
||||||
readonly CROSS_PROCESS_PREFIX: string;
|
readonly CROSS_PROCESS_PREFIX: string;
|
||||||
|
readonly HELLO_WORLD: string;
|
||||||
|
|
||||||
static async create(port: number): Promise<TestServer> {
|
static async create(port: number): Promise<TestServer> {
|
||||||
const server = new TestServer(port);
|
const server = new TestServer(port);
|
||||||
@@ -67,8 +68,9 @@ export class TestServer {
|
|||||||
const same_origin = 'localhost';
|
const same_origin = 'localhost';
|
||||||
const protocol = sslOptions ? 'https' : 'http';
|
const protocol = sslOptions ? 'https' : 'http';
|
||||||
this.PORT = port;
|
this.PORT = port;
|
||||||
this.PREFIX = `${protocol}://${same_origin}:${port}`;
|
this.PREFIX = `${protocol}://${same_origin}:${port}/`;
|
||||||
this.CROSS_PROCESS_PREFIX = `${protocol}://${cross_origin}:${port}`;
|
this.CROSS_PROCESS_PREFIX = `${protocol}://${cross_origin}:${port}/`;
|
||||||
|
this.HELLO_WORLD = `${this.PREFIX}hello-world`;
|
||||||
}
|
}
|
||||||
|
|
||||||
setCSP(path: string, csp: string) {
|
setCSP(path: string, csp: string) {
|
||||||
@@ -88,6 +90,13 @@ export class TestServer {
|
|||||||
this._routes.set(path, handler);
|
this._routes.set(path, handler);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
setContent(path: string, content: string, mimeType: string) {
|
||||||
|
this.route(path, (req, res) => {
|
||||||
|
res.writeHead(200, { 'Content-Type': mimeType });
|
||||||
|
res.end(mimeType === 'text/html' ? `<!DOCTYPE html>${content}` : content);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
redirect(from: string, to: string) {
|
redirect(from: string, to: string) {
|
||||||
this.route(from, (req, res) => {
|
this.route(from, (req, res) => {
|
||||||
const headers = this._extraHeaders.get(req.url!) || {};
|
const headers = this._extraHeaders.get(req.url!) || {};
|
||||||
@@ -120,6 +129,15 @@ export class TestServer {
|
|||||||
for (const subscriber of this._requestSubscribers.values())
|
for (const subscriber of this._requestSubscribers.values())
|
||||||
subscriber[rejectSymbol].call(null, error);
|
subscriber[rejectSymbol].call(null, error);
|
||||||
this._requestSubscribers.clear();
|
this._requestSubscribers.clear();
|
||||||
|
|
||||||
|
this.setContent('/favicon.ico', '', 'image/x-icon');
|
||||||
|
|
||||||
|
this.setContent('/', ``, 'text/html');
|
||||||
|
|
||||||
|
this.setContent('/hello-world', `
|
||||||
|
<title>Title</title>
|
||||||
|
<body>Hello, world!</body>
|
||||||
|
`, 'text/html');
|
||||||
}
|
}
|
||||||
|
|
||||||
_onRequest(request: http.IncomingMessage, response: http.ServerResponse) {
|
_onRequest(request: http.IncomingMessage, response: http.ServerResponse) {
|
||||||
@@ -144,7 +162,11 @@ export class TestServer {
|
|||||||
this._requestSubscribers.delete(path);
|
this._requestSubscribers.delete(path);
|
||||||
}
|
}
|
||||||
const handler = this._routes.get(path);
|
const handler = this._routes.get(path);
|
||||||
if (handler)
|
if (handler) {
|
||||||
handler.call(null, request, response);
|
handler.call(null, request, response);
|
||||||
|
} else {
|
||||||
|
response.writeHead(404);
|
||||||
|
response.end();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
85
tests/wait.spec.ts
Normal file
85
tests/wait.spec.ts
Normal file
@@ -0,0 +1,85 @@
|
|||||||
|
/**
|
||||||
|
* Copyright (c) Microsoft Corporation.
|
||||||
|
*
|
||||||
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
* you may not use this file except in compliance with the License.
|
||||||
|
* You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing, software
|
||||||
|
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
* See the License for the specific language governing permissions and
|
||||||
|
* limitations under the License.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { test, expect } from './fixtures.js';
|
||||||
|
|
||||||
|
test('browser_wait_for(text)', async ({ client, server }) => {
|
||||||
|
server.setContent('/', `
|
||||||
|
<script>
|
||||||
|
function update() {
|
||||||
|
setTimeout(() => {
|
||||||
|
document.querySelector('div').textContent = 'Text to appear';
|
||||||
|
}, 1000);
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
<body>
|
||||||
|
<button onclick="update()">Click me</button>
|
||||||
|
<div>Text to disappear</div>
|
||||||
|
</body>
|
||||||
|
`, 'text/html');
|
||||||
|
|
||||||
|
await client.callTool({
|
||||||
|
name: 'browser_navigate',
|
||||||
|
arguments: { url: server.PREFIX },
|
||||||
|
});
|
||||||
|
|
||||||
|
await client.callTool({
|
||||||
|
name: 'browser_click',
|
||||||
|
arguments: {
|
||||||
|
element: 'Click me',
|
||||||
|
ref: 'e2',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(await client.callTool({
|
||||||
|
name: 'browser_wait_for',
|
||||||
|
arguments: { text: 'Text to appear' },
|
||||||
|
})).toContainTextContent(`- generic [ref=e3]: Text to appear`);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('browser_wait_for(textGone)', async ({ client, server }) => {
|
||||||
|
server.setContent('/', `
|
||||||
|
<script>
|
||||||
|
function update() {
|
||||||
|
setTimeout(() => {
|
||||||
|
document.querySelector('div').textContent = 'Text to appear';
|
||||||
|
}, 1000);
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
<body>
|
||||||
|
<button onclick="update()">Click me</button>
|
||||||
|
<div>Text to disappear</div>
|
||||||
|
</body>
|
||||||
|
`, 'text/html');
|
||||||
|
|
||||||
|
await client.callTool({
|
||||||
|
name: 'browser_navigate',
|
||||||
|
arguments: { url: server.PREFIX },
|
||||||
|
});
|
||||||
|
|
||||||
|
await client.callTool({
|
||||||
|
name: 'browser_click',
|
||||||
|
arguments: {
|
||||||
|
element: 'Click me',
|
||||||
|
ref: 'e2',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(await client.callTool({
|
||||||
|
name: 'browser_wait_for',
|
||||||
|
arguments: { textGone: 'Text to disappear' },
|
||||||
|
})).toContainTextContent(`- generic [ref=e3]: Text to appear`);
|
||||||
|
});
|
||||||
@@ -1,7 +1,6 @@
|
|||||||
{
|
{
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"target": "ESNext",
|
"target": "ESNext",
|
||||||
"skipLibCheck": true,
|
|
||||||
"esModuleInterop": true,
|
"esModuleInterop": true,
|
||||||
"moduleResolution": "nodenext",
|
"moduleResolution": "nodenext",
|
||||||
"strict": true,
|
"strict": true,
|
||||||
|
|||||||
Reference in New Issue
Block a user