Compare commits
39 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
43aa4001b5 | ||
|
|
7e087af6a6 | ||
|
|
927a1280f1 | ||
|
|
292e75d464 | ||
|
|
2c9376e50f | ||
|
|
062cdd0704 | ||
|
|
a713300c5b | ||
|
|
a15f0f301b | ||
|
|
23ce973377 | ||
|
|
685dea9e19 | ||
|
|
878be97668 | ||
|
|
6d6b1a384b | ||
|
|
fd22def4c5 | ||
|
|
1b60870f50 | ||
|
|
1c760b3826 | ||
|
|
9efaea6a1c | ||
|
|
3f72fe53ec | ||
|
|
40d125f0bb | ||
|
|
21d2f80fef | ||
|
|
6efdc90078 | ||
|
|
ad4147da54 | ||
|
|
69703cc882 | ||
|
|
4147e21a3a | ||
|
|
80c9b93b72 | ||
|
|
12e72a96c4 | ||
|
|
697a69a8c2 | ||
|
|
6e76d5e550 | ||
|
|
26779ceb20 | ||
|
|
23704ace1f | ||
|
|
b02370df2f | ||
|
|
bf7dbabca4 | ||
|
|
7256ee3701 | ||
|
|
0ed0bcd914 | ||
|
|
4d95761f66 | ||
|
|
b9dc323734 | ||
|
|
586492a3f0 | ||
|
|
f7e9bae571 | ||
|
|
1bc3c761de | ||
|
|
c80f7cf222 |
3
.github/workflows/ci.yml
vendored
3
.github/workflows/ci.yml
vendored
@@ -21,7 +21,6 @@ jobs:
|
|||||||
- run: npm run build
|
- run: npm run build
|
||||||
- name: Run ESLint
|
- name: Run ESLint
|
||||||
run: npm run lint
|
run: npm run lint
|
||||||
- run: npm run update-readme
|
|
||||||
- name: Ensure no changes
|
- name: Ensure no changes
|
||||||
run: git diff --exit-code
|
run: git diff --exit-code
|
||||||
|
|
||||||
@@ -58,4 +57,4 @@ jobs:
|
|||||||
run: npx playwright install --with-deps
|
run: npx playwright install --with-deps
|
||||||
|
|
||||||
- name: Run tests
|
- name: Run tests
|
||||||
run: npm test
|
run: npm test -- --forbid-only
|
||||||
|
|||||||
4
.gitignore
vendored
4
.gitignore
vendored
@@ -1,4 +1,8 @@
|
|||||||
lib/
|
lib/
|
||||||
node_modules/
|
node_modules/
|
||||||
test-results/
|
test-results/
|
||||||
|
playwright-report/
|
||||||
.vscode/mcp.json
|
.vscode/mcp.json
|
||||||
|
|
||||||
|
.idea
|
||||||
|
.DS_Store
|
||||||
|
|||||||
@@ -4,3 +4,4 @@ LICENSE
|
|||||||
!lib/**/*.js
|
!lib/**/*.js
|
||||||
!cli.js
|
!cli.js
|
||||||
!index.*
|
!index.*
|
||||||
|
!config.d.ts
|
||||||
|
|||||||
201
README.md
201
README.md
@@ -15,9 +15,14 @@ A Model Context Protocol (MCP) server that provides browser automation capabilit
|
|||||||
- Automated testing driven by LLMs
|
- Automated testing driven by LLMs
|
||||||
- General-purpose browser interaction for agents
|
- General-purpose browser interaction for agents
|
||||||
|
|
||||||
### Example config
|
<!--
|
||||||
|
// Generate using:
|
||||||
|
node utils/generate-links.js
|
||||||
|
-->
|
||||||
|
|
||||||
#### NPX
|
[<img src="https://img.shields.io/badge/VS_Code-VS_Code?style=flat-square&label=Install%20Server&color=0098FF" alt="Install in VS Code">](https://insiders.vscode.dev/redirect?url=vscode%3Amcp%2Finstall%3F%257B%2522name%2522%253A%2522playwright%2522%252C%2522command%2522%253A%2522npx%2522%252C%2522args%2522%253A%255B%2522%2540playwright%252Fmcp%2540latest%2522%255D%257D) [<img alt="Install in VS Code Insiders" src="https://img.shields.io/badge/VS_Code_Insiders-VS_Code_Insiders?style=flat-square&label=Install%20Server&color=24bfa5">](https://insiders.vscode.dev/redirect?url=vscode-insiders%3Amcp%2Finstall%3F%257B%2522name%2522%253A%2522playwright%2522%252C%2522command%2522%253A%2522npx%2522%252C%2522args%2522%253A%255B%2522%2540playwright%252Fmcp%2540latest%2522%255D%257D)
|
||||||
|
|
||||||
|
### Example config
|
||||||
|
|
||||||
```js
|
```js
|
||||||
{
|
{
|
||||||
@@ -32,35 +37,29 @@ A Model Context Protocol (MCP) server that provides browser automation capabilit
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
#### Installation in VS Code
|
### Table of Contents
|
||||||
|
|
||||||
Install the Playwright MCP server in VS Code using one of these buttons:
|
- [Installation in VS Code](#installation-in-vs-code)
|
||||||
|
- [Command line](#command-line)
|
||||||
|
- [User profile](#user-profile)
|
||||||
|
- [Configuration file](#configuration-file)
|
||||||
|
- [Running on Linux](#running-on-linux)
|
||||||
|
- [Docker](#docker)
|
||||||
|
- [Programmatic usage](#programmatic-usage)
|
||||||
|
- [Tool modes](#tool-modes)
|
||||||
|
|
||||||
<!--
|
### Installation in VS Code
|
||||||
// Generate using?:
|
|
||||||
const config = JSON.stringify({ name: 'playwright', command: 'npx', args: ["-y", "@playwright/mcp@latest"] });
|
|
||||||
const urlForWebsites = `vscode:mcp/install?${encodeURIComponent(config)}`;
|
|
||||||
// Github markdown does not allow linking to `vscode:` directly, so you can use our redirect:
|
|
||||||
const urlForGithub = `https://insiders.vscode.dev/redirect?url=${encodeURIComponent(urlForWebsites)}`;
|
|
||||||
-->
|
|
||||||
|
|
||||||
[<img src="https://img.shields.io/badge/VS_Code-VS_Code?style=flat-square&label=Install%20Server&color=0098FF" alt="Install in VS Code">](https://insiders.vscode.dev/redirect?url=vscode%3Amcp%2Finstall%3F%257B%2522name%2522%253A%2522playwright%2522%252C%2522command%2522%253A%2522npx%2522%252C%2522args%2522%253A%255B%2522-y%2522%252C%2522%2540playwright%252Fmcp%2540latest%2522%255D%257D) [<img alt="Install in VS Code Insiders" src="https://img.shields.io/badge/VS_Code_Insiders-VS_Code_Insiders?style=flat-square&label=Install%20Server&color=24bfa5">](https://insiders.vscode.dev/redirect?url=vscode-insiders%3Amcp%2Finstall%3F%257B%2522name%2522%253A%2522playwright%2522%252C%2522command%2522%253A%2522npx%2522%252C%2522args%2522%253A%255B%2522-y%2522%252C%2522%2540playwright%252Fmcp%2540latest%2522%255D%257D)
|
You can install the Playwright MCP server using the VS Code CLI:
|
||||||
|
|
||||||
Alternatively, you can install the Playwright MCP server using the VS Code CLI:
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# For VS Code
|
# For VS Code
|
||||||
code --add-mcp '{"name":"playwright","command":"npx","args":["@playwright/mcp@latest"]}'
|
code --add-mcp '{"name":"playwright","command":"npx","args":["@playwright/mcp@latest"]}'
|
||||||
```
|
```
|
||||||
|
|
||||||
```bash
|
|
||||||
# For VS Code Insiders
|
|
||||||
code-insiders --add-mcp '{"name":"playwright","command":"npx","args":["@playwright/mcp@latest"]}'
|
|
||||||
```
|
|
||||||
|
|
||||||
After installation, the Playwright MCP server will be available for use with your GitHub Copilot agent in VS Code.
|
After installation, the Playwright MCP server will be available for use with your GitHub Copilot agent in VS Code.
|
||||||
|
|
||||||
### CLI Options
|
### Command line
|
||||||
|
|
||||||
The Playwright MCP server supports the following command-line options:
|
The Playwright MCP server supports the following command-line options:
|
||||||
|
|
||||||
@@ -73,42 +72,103 @@ The Playwright MCP server supports the following command-line options:
|
|||||||
- `--cdp-endpoint <endpoint>`: CDP endpoint to connect to
|
- `--cdp-endpoint <endpoint>`: CDP endpoint to connect to
|
||||||
- `--executable-path <path>`: Path to the browser executable
|
- `--executable-path <path>`: Path to the browser executable
|
||||||
- `--headless`: Run browser in headless mode (headed by default)
|
- `--headless`: Run browser in headless mode (headed by default)
|
||||||
- `--port <port>`: Port to listen on for SSE transport
|
- `--device`: Emulate mobile device
|
||||||
- `--user-data-dir <path>`: Path to the user data directory
|
- `--user-data-dir <path>`: Path to the user data directory
|
||||||
|
- `--port <port>`: Port to listen on for SSE transport
|
||||||
|
- `--host <host>`: Host to bind server to. Default is localhost. Use 0.0.0.0 to bind to all interfaces.
|
||||||
- `--vision`: Run server that uses screenshots (Aria snapshots are used by default)
|
- `--vision`: Run server that uses screenshots (Aria snapshots are used by default)
|
||||||
|
- `--config <path>`: Path to the configuration file
|
||||||
|
|
||||||
### User data directory
|
### User profile
|
||||||
|
|
||||||
Playwright MCP will launch the browser with the new profile, located at
|
Playwright MCP will launch the browser with the new profile, located at
|
||||||
|
|
||||||
```
|
```
|
||||||
- `%USERPROFILE%\AppData\Local\ms-playwright\mcp-chrome-profile` on Windows
|
- `%USERPROFILE%\AppData\Local\ms-playwright\mcp-{channel}-profile` on Windows
|
||||||
- `~/Library/Caches/ms-playwright/mcp-chrome-profile` on macOS
|
- `~/Library/Caches/ms-playwright/mcp-{channel}-profile` on macOS
|
||||||
- `~/.cache/ms-playwright/mcp-chrome-profile` on Linux
|
- `~/.cache/ms-playwright/mcp-{channel}-profile` on Linux
|
||||||
```
|
```
|
||||||
|
|
||||||
All the logged in information will be stored in that profile, you can delete it between sessions if you'd like to clear the offline state.
|
All the logged in information will be stored in that profile, you can delete it between sessions if you'd like to clear the offline state.
|
||||||
|
|
||||||
|
### Configuration file
|
||||||
|
|
||||||
### Running headless browser (Browser without GUI).
|
The Playwright MCP server can be configured using a JSON configuration file. Here's the complete configuration format:
|
||||||
|
|
||||||
This mode is useful for background or batch operations.
|
```typescript
|
||||||
|
|
||||||
```js
|
|
||||||
{
|
{
|
||||||
"mcpServers": {
|
// Browser configuration
|
||||||
"playwright": {
|
browser?: {
|
||||||
"command": "npx",
|
// Browser type to use (chromium, firefox, or webkit)
|
||||||
"args": [
|
browserName?: 'chromium' | 'firefox' | 'webkit';
|
||||||
"@playwright/mcp@latest",
|
|
||||||
"--headless"
|
// Path to user data directory for browser profile persistence
|
||||||
]
|
userDataDir?: string;
|
||||||
|
|
||||||
|
// Browser launch options (see Playwright docs)
|
||||||
|
// @see https://playwright.dev/docs/api/class-browsertype#browser-type-launch
|
||||||
|
launchOptions?: {
|
||||||
|
channel?: string; // Browser channel (e.g. 'chrome')
|
||||||
|
headless?: boolean; // Run in headless mode
|
||||||
|
executablePath?: string; // Path to browser executable
|
||||||
|
// ... other Playwright launch options
|
||||||
|
};
|
||||||
|
|
||||||
|
// Browser context options
|
||||||
|
// @see https://playwright.dev/docs/api/class-browser#browser-new-context
|
||||||
|
contextOptions?: {
|
||||||
|
viewport?: { width: number, height: number };
|
||||||
|
// ... other Playwright context options
|
||||||
|
};
|
||||||
|
|
||||||
|
// CDP endpoint for connecting to existing browser
|
||||||
|
cdpEndpoint?: string;
|
||||||
|
|
||||||
|
// Remote Playwright server endpoint
|
||||||
|
remoteEndpoint?: string;
|
||||||
|
},
|
||||||
|
|
||||||
|
// Server configuration
|
||||||
|
server?: {
|
||||||
|
port?: number; // Port to listen on
|
||||||
|
host?: string; // Host to bind to (default: localhost)
|
||||||
|
},
|
||||||
|
|
||||||
|
// List of enabled capabilities
|
||||||
|
capabilities?: Array<
|
||||||
|
'core' | // Core browser automation
|
||||||
|
'tabs' | // Tab management
|
||||||
|
'pdf' | // PDF generation
|
||||||
|
'history' | // Browser history
|
||||||
|
'wait' | // Wait utilities
|
||||||
|
'files' | // File handling
|
||||||
|
'install' | // Browser installation
|
||||||
|
'testing' // Testing
|
||||||
|
>;
|
||||||
|
|
||||||
|
// Enable vision mode (screenshots instead of accessibility snapshots)
|
||||||
|
vision?: boolean;
|
||||||
|
|
||||||
|
// Directory for output files
|
||||||
|
outputDir?: string;
|
||||||
|
|
||||||
|
// Tool-specific configurations
|
||||||
|
tools?: {
|
||||||
|
browser_take_screenshot?: {
|
||||||
|
// Disable base64-encoded image responses
|
||||||
|
omitBase64?: boolean;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
### Running headed browser on Linux w/o DISPLAY
|
You can specify the configuration file using the `--config` command line option:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npx @playwright/mcp@latest --config path/to/config.json
|
||||||
|
```
|
||||||
|
|
||||||
|
### Running on Linux
|
||||||
|
|
||||||
When running headed browser on system w/o display or from worker processes of the IDEs,
|
When running headed browser on system w/o display or from worker processes of the IDEs,
|
||||||
run the MCP server from environment with the DISPLAY and pass the `--port` flag to enable SSE transport.
|
run the MCP server from environment with the DISPLAY and pass the `--port` flag to enable SSE transport.
|
||||||
@@ -132,6 +192,7 @@ And then in MCP client config, set the `url` to the SSE endpoint:
|
|||||||
### Docker
|
### Docker
|
||||||
|
|
||||||
**NOTE:** The Docker implementation only supports headless chromium at the moment.
|
**NOTE:** The Docker implementation only supports headless chromium at the moment.
|
||||||
|
|
||||||
```js
|
```js
|
||||||
{
|
{
|
||||||
"mcpServers": {
|
"mcpServers": {
|
||||||
@@ -143,7 +204,33 @@ And then in MCP client config, set the `url` to the SSE endpoint:
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
### Tool Modes
|
You can build the Docker image yourself.
|
||||||
|
|
||||||
|
```
|
||||||
|
docker build -t mcp/playwright .
|
||||||
|
```
|
||||||
|
|
||||||
|
### Programmatic usage
|
||||||
|
|
||||||
|
```js
|
||||||
|
import http from 'http';
|
||||||
|
|
||||||
|
import { createServer } from '@playwright/mcp';
|
||||||
|
import { SSEServerTransport } from '@modelcontextprotocol/sdk/server/sse.js';
|
||||||
|
|
||||||
|
http.createServer(async (req, res) => {
|
||||||
|
// ...
|
||||||
|
|
||||||
|
// Creates a headless Playwright MCP server with SSE transport
|
||||||
|
const mcpServer = await createServer({ headless: true });
|
||||||
|
const transport = new SSEServerTransport('/messages', res);
|
||||||
|
await mcpServer.connect(transport);
|
||||||
|
|
||||||
|
// ...
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### Tool modes
|
||||||
|
|
||||||
The tools are available in two modes:
|
The tools are available in two modes:
|
||||||
|
|
||||||
@@ -169,33 +256,6 @@ To use Vision Mode, add the `--vision` flag when starting the server:
|
|||||||
Vision Mode works best with the computer use models that are able to interact with elements using
|
Vision Mode works best with the computer use models that are able to interact with elements using
|
||||||
X Y coordinate space, based on the provided screenshot.
|
X Y coordinate space, based on the provided screenshot.
|
||||||
|
|
||||||
### Build with Docker
|
|
||||||
|
|
||||||
You can build the Docker image yourself.
|
|
||||||
```
|
|
||||||
docker build -t mcp/playwright .
|
|
||||||
```
|
|
||||||
|
|
||||||
### Programmatic usage with custom transports
|
|
||||||
|
|
||||||
```js
|
|
||||||
import http from 'http';
|
|
||||||
|
|
||||||
import { createServer } from '@playwright/mcp';
|
|
||||||
import { SSEServerTransport } from '@modelcontextprotocol/sdk/server/sse.js';
|
|
||||||
|
|
||||||
http.createServer(async (req, res) => {
|
|
||||||
// ...
|
|
||||||
|
|
||||||
// Creates a headless Playwright MCP server with SSE transport
|
|
||||||
const mcpServer = await createServer({ headless: true });
|
|
||||||
const transport = new SSEServerTransport('/messages', res);
|
|
||||||
await mcpServer.connect(transport);
|
|
||||||
|
|
||||||
// ...
|
|
||||||
});
|
|
||||||
|
|
||||||
```
|
|
||||||
|
|
||||||
<!--- Generated by update-readme.js -->
|
<!--- Generated by update-readme.js -->
|
||||||
|
|
||||||
@@ -426,4 +486,15 @@ http.createServer(async (req, res) => {
|
|||||||
- `accept` (boolean): Whether to accept the dialog.
|
- `accept` (boolean): Whether to accept the dialog.
|
||||||
- `promptText` (string, optional): The text of the prompt in case of a prompt dialog.
|
- `promptText` (string, optional): The text of the prompt in case of a prompt dialog.
|
||||||
|
|
||||||
|
### Testing
|
||||||
|
|
||||||
|
<!-- NOTE: This has been generated via update-readme.js -->
|
||||||
|
|
||||||
|
- **browser_generate_playwright_test**
|
||||||
|
- Description: Generate a Playwright test for given scenario
|
||||||
|
- Parameters:
|
||||||
|
- `name` (string): The name of the test
|
||||||
|
- `description` (string): The description of the test
|
||||||
|
- `steps` (array): The steps of the test
|
||||||
|
|
||||||
<!--- End of generated section -->
|
<!--- End of generated section -->
|
||||||
|
|||||||
2
cli.js
2
cli.js
@@ -15,4 +15,4 @@
|
|||||||
* limitations under the License.
|
* limitations under the License.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
require('./lib/program');
|
import './lib/program.js';
|
||||||
|
|||||||
113
config.d.ts
vendored
Normal file
113
config.d.ts
vendored
Normal file
@@ -0,0 +1,113 @@
|
|||||||
|
/**
|
||||||
|
* Copyright (c) Microsoft Corporation.
|
||||||
|
*
|
||||||
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
* you may not use this file except in compliance with the License.
|
||||||
|
* You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing, software
|
||||||
|
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
* See the License for the specific language governing permissions and
|
||||||
|
* limitations under the License.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type * as playwright from 'playwright';
|
||||||
|
|
||||||
|
export type ToolCapability = 'core' | 'tabs' | 'pdf' | 'history' | 'wait' | 'files' | 'install' | 'testing';
|
||||||
|
|
||||||
|
export type Config = {
|
||||||
|
/**
|
||||||
|
* The browser to use.
|
||||||
|
*/
|
||||||
|
browser?: {
|
||||||
|
/**
|
||||||
|
* The type of browser to use.
|
||||||
|
*/
|
||||||
|
browserName?: 'chromium' | 'firefox' | 'webkit';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Path to a user data directory for browser profile persistence.
|
||||||
|
* Temporary directory is created by default.
|
||||||
|
*/
|
||||||
|
userDataDir?: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Launch options passed to
|
||||||
|
* @see https://playwright.dev/docs/api/class-browsertype#browser-type-launch-persistent-context
|
||||||
|
*
|
||||||
|
* This is useful for settings options like `channel`, `headless`, `executablePath`, etc.
|
||||||
|
*/
|
||||||
|
launchOptions?: playwright.BrowserLaunchOptions;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Context options for the browser context.
|
||||||
|
*
|
||||||
|
* This is useful for settings options like `viewport`.
|
||||||
|
*/
|
||||||
|
contextOptions?: playwright.BrowserContextOptions;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Chrome DevTools Protocol endpoint to connect to an existing browser instance in case of Chromium family browsers.
|
||||||
|
*/
|
||||||
|
cdpEndpoint?: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remote endpoint to connect to an existing Playwright server.
|
||||||
|
*/
|
||||||
|
remoteEndpoint?: string;
|
||||||
|
},
|
||||||
|
|
||||||
|
server?: {
|
||||||
|
/**
|
||||||
|
* The port to listen on for SSE or MCP transport.
|
||||||
|
*/
|
||||||
|
port?: number;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The host to bind the server to. Default is localhost. Use 0.0.0.0 to bind to all interfaces.
|
||||||
|
*/
|
||||||
|
host?: string;
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* List of enabled tool capabilities. Possible values:
|
||||||
|
* - 'core': Core browser automation features.
|
||||||
|
* - 'tabs': Tab management features.
|
||||||
|
* - 'pdf': PDF generation and manipulation.
|
||||||
|
* - 'history': Browser history access.
|
||||||
|
* - 'wait': Wait and timing utilities.
|
||||||
|
* - 'files': File upload/download support.
|
||||||
|
* - 'install': Browser installation utilities.
|
||||||
|
*/
|
||||||
|
capabilities?: ToolCapability[];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Run server that uses screenshots (Aria snapshots are used by default).
|
||||||
|
*/
|
||||||
|
vision?: boolean;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The directory to save output files.
|
||||||
|
*/
|
||||||
|
outputDir?: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Configuration for specific tools.
|
||||||
|
*/
|
||||||
|
tools?: {
|
||||||
|
/**
|
||||||
|
* Configuration for the browser_take_screenshot tool.
|
||||||
|
*/
|
||||||
|
browser_take_screenshot?: {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether to disable base64-encoded image responses to the clients that
|
||||||
|
* don't support binary data or prefer to save on tokens.
|
||||||
|
*/
|
||||||
|
omitBase64?: boolean;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -33,6 +33,8 @@ const plugins = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const baseRules = {
|
export const baseRules = {
|
||||||
|
"import/extensions": ["error", "ignorePackages", {ts: "always"}],
|
||||||
|
"@typescript-eslint/no-floating-promises": "error",
|
||||||
"@typescript-eslint/no-unused-vars": [
|
"@typescript-eslint/no-unused-vars": [
|
||||||
2,
|
2,
|
||||||
{ args: "none", caughtErrors: "none" },
|
{ args: "none", caughtErrors: "none" },
|
||||||
@@ -178,12 +180,16 @@ export const baseRules = {
|
|||||||
|
|
||||||
// react
|
// react
|
||||||
"react/react-in-jsx-scope": 0,
|
"react/react-in-jsx-scope": 0,
|
||||||
|
"no-console": 2,
|
||||||
};
|
};
|
||||||
|
|
||||||
const languageOptions = {
|
const languageOptions = {
|
||||||
parser: tsParser,
|
parser: tsParser,
|
||||||
ecmaVersion: 9,
|
ecmaVersion: 9,
|
||||||
sourceType: "module",
|
sourceType: "module",
|
||||||
|
parserOptions: {
|
||||||
|
project: path.join(fileURLToPath(import.meta.url), "..", "tsconfig.all.json"),
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
export default [
|
export default [
|
||||||
|
|||||||
47
examples.md
47
examples.md
@@ -1,47 +0,0 @@
|
|||||||
# Examples
|
|
||||||
|
|
||||||
Below are some examples of prompts to use with Playwright MCP. Draw inspiration from them to create your own. If you come up with a new prompt that works well, feel free to let us know in an issue or pull request.
|
|
||||||
|
|
||||||
## Write a regression test for a bug report
|
|
||||||
|
|
||||||
We have received a user report about a bug on our website https://debs-obrien.github.io/playwright-movies-app. I'd like you to verify if this bug exists by trying to follow the steps. If you see different behaviour, explain how it's different. If you see the same behaviour, please write me a Playwright regression test that encapsulates the buggy user flow. While writing the test, draw inspiration from the Playwright code you ran. Here's the user report:
|
|
||||||
|
|
||||||
Howdy. I love your movies site, but there's something fishy going on. I'm looking at The Garfield Movie - a spectacular film - but somehow it's showing the wrong length. The movie is 90mins long, I measured myself yesterday! But somehow your site is claiming 101mins. Am I looking at it wrong, or is there a bug?
|
|
||||||
|
|
||||||
**Output:**
|
|
||||||
|
|
||||||
```ts
|
|
||||||
import { test, expect } from '@playwright/test';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Regression test for bug: The Garfield Movie showing incorrect runtime
|
|
||||||
* User reported actual runtime is 90 minutes, but site shows 101 minutes
|
|
||||||
*/
|
|
||||||
test('verify The Garfield Movie runtime bug', async ({ page }) => {
|
|
||||||
// Navigate to the movies app home page
|
|
||||||
await page.goto('https://debs-obrien.github.io/playwright-movies-app');
|
|
||||||
|
|
||||||
// Wait for page to load completely
|
|
||||||
await expect(page.getByRole('heading', { name: 'Popular', level: 1 })).toBeVisible();
|
|
||||||
|
|
||||||
// Look for The Garfield Movie and click on it
|
|
||||||
const garfieldMovieLink = page.getByRole('link', { name: /poster of The Garfield Movie/i });
|
|
||||||
await expect(garfieldMovieLink).toBeVisible();
|
|
||||||
await garfieldMovieLink.click();
|
|
||||||
|
|
||||||
// Verify we're on the correct movie page
|
|
||||||
await expect(page.getByRole('heading', { name: 'The Garfield Movie', level: 1 })).toBeVisible();
|
|
||||||
|
|
||||||
// Check the runtime information
|
|
||||||
// The bug: Expected runtime should be 90 min, but site shows 101 min
|
|
||||||
const movieInfoText = await page.locator('text=English / 101 min. / 2024').textContent();
|
|
||||||
|
|
||||||
// This test will fail because of the bug (which is what we want to demonstrate)
|
|
||||||
// Once fixed, this assertion should be updated to the correct runtime (90 min)
|
|
||||||
expect(movieInfoText).toContain('90 min');
|
|
||||||
|
|
||||||
// Alternative assertion that verifies the incorrect runtime is still present
|
|
||||||
// Uncomment this and comment the above assertion to verify the bug exists
|
|
||||||
// expect(movieInfoText).toContain('101 min');
|
|
||||||
});
|
|
||||||
```
|
|
||||||
10
examples/generate-test.md
Normal file
10
examples/generate-test.md
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
Generate test for scenario:
|
||||||
|
|
||||||
|
## GitHub PR Checks Navigation Checklist
|
||||||
|
|
||||||
|
1. Open the [Microsoft Playwright GitHub repository](https://github.com/microsoft/playwright).
|
||||||
|
2. Click on the **Pull requests** tab.
|
||||||
|
3. Find and open the pull request titled **"chore: make noWaitAfter a default"**.
|
||||||
|
4. Switch to the **Checks** tab for that pull request.
|
||||||
|
5. Expand the **infra** check suite to view its jobs.
|
||||||
|
6. Click on the **docs & lint** job to view its details.
|
||||||
41
index.d.ts
vendored
41
index.d.ts
vendored
@@ -17,44 +17,7 @@
|
|||||||
|
|
||||||
import type { Server } from '@modelcontextprotocol/sdk/server/index.js';
|
import type { Server } from '@modelcontextprotocol/sdk/server/index.js';
|
||||||
|
|
||||||
type ToolCapability = 'core' | 'tabs' | 'pdf' | 'history' | 'wait' | 'files' | 'install';
|
import type { Config } from './config';
|
||||||
|
|
||||||
type Options = {
|
export declare function createServer(config?: Config): Promise<Server>;
|
||||||
/**
|
|
||||||
* The browser to use (e.g., 'chrome', 'chromium', 'firefox', 'webkit', 'msedge').
|
|
||||||
*/
|
|
||||||
browser?: string;
|
|
||||||
/**
|
|
||||||
* Path to a user data directory for browser profile persistence.
|
|
||||||
*/
|
|
||||||
userDataDir?: string;
|
|
||||||
/**
|
|
||||||
* Whether to run the browser in headless mode (default: true).
|
|
||||||
*/
|
|
||||||
headless?: boolean;
|
|
||||||
/**
|
|
||||||
* Path to a custom browser executable.
|
|
||||||
*/
|
|
||||||
executablePath?: string;
|
|
||||||
/**
|
|
||||||
* Chrome DevTools Protocol endpoint to connect to an existing browser instance.
|
|
||||||
*/
|
|
||||||
cdpEndpoint?: string;
|
|
||||||
/**
|
|
||||||
* Enable vision capabilities (e.g., visual automation or OCR).
|
|
||||||
*/
|
|
||||||
vision?: boolean;
|
|
||||||
/**
|
|
||||||
* List of enabled tool capabilities. Possible values:
|
|
||||||
* - 'core': Core browser automation features.
|
|
||||||
* - 'tabs': Tab management features.
|
|
||||||
* - 'pdf': PDF generation and manipulation.
|
|
||||||
* - 'history': Browser history access.
|
|
||||||
* - 'wait': Wait and timing utilities.
|
|
||||||
* - 'files': File upload/download support.
|
|
||||||
* - 'install': Browser installation utilities.
|
|
||||||
*/
|
|
||||||
capabilities?: ToolCapability[];
|
|
||||||
};
|
|
||||||
export declare function createServer(options?: Options): Promise<Server>;
|
|
||||||
export {};
|
export {};
|
||||||
|
|||||||
4
index.js
4
index.js
@@ -15,5 +15,5 @@
|
|||||||
* limitations under the License.
|
* limitations under the License.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
const { createServer } = require('./lib/index');
|
import { createServer } from './lib/index';
|
||||||
module.exports = { createServer };
|
export default { createServer };
|
||||||
|
|||||||
53
package-lock.json
generated
53
package-lock.json
generated
@@ -1,17 +1,17 @@
|
|||||||
{
|
{
|
||||||
"name": "@playwright/mcp",
|
"name": "@playwright/mcp",
|
||||||
"version": "0.0.14",
|
"version": "0.0.19",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "@playwright/mcp",
|
"name": "@playwright/mcp",
|
||||||
"version": "0.0.14",
|
"version": "0.0.19",
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@modelcontextprotocol/sdk": "^1.6.1",
|
"@modelcontextprotocol/sdk": "^1.10.1",
|
||||||
"commander": "^13.1.0",
|
"commander": "^13.1.0",
|
||||||
"playwright": "^1.52.0-alpha-1743163434000",
|
"playwright": "1.53.0-alpha-1746218818000",
|
||||||
"yaml": "^2.7.1",
|
"yaml": "^2.7.1",
|
||||||
"zod-to-json-schema": "^3.24.4"
|
"zod-to-json-schema": "^3.24.4"
|
||||||
},
|
},
|
||||||
@@ -21,7 +21,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.52.0-alpha-1743163434000",
|
"@playwright/test": "1.53.0-alpha-1746218818000",
|
||||||
"@stylistic/eslint-plugin": "^3.0.1",
|
"@stylistic/eslint-plugin": "^3.0.1",
|
||||||
"@types/node": "^22.13.10",
|
"@types/node": "^22.13.10",
|
||||||
"@typescript-eslint/eslint-plugin": "^8.26.1",
|
"@typescript-eslint/eslint-plugin": "^8.26.1",
|
||||||
@@ -228,17 +228,18 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@modelcontextprotocol/sdk": {
|
"node_modules/@modelcontextprotocol/sdk": {
|
||||||
"version": "1.7.0",
|
"version": "1.10.1",
|
||||||
"resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.7.0.tgz",
|
"resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.10.1.tgz",
|
||||||
"integrity": "sha512-IYPe/FLpvF3IZrd/f5p5ffmWhMc3aEMuM2wGJASDqC2Ge7qatVCdbfPx3n/5xFeb19xN0j/911M2AaFuircsWA==",
|
"integrity": "sha512-xNYdFdkJqEfIaTVP1gPKoEvluACHZsHZegIoICX8DM1o6Qf3G5u2BQJHmgd0n4YgRPqqK/u1ujQvrgAxxSJT9w==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"content-type": "^1.0.5",
|
"content-type": "^1.0.5",
|
||||||
"cors": "^2.8.5",
|
"cors": "^2.8.5",
|
||||||
|
"cross-spawn": "^7.0.3",
|
||||||
"eventsource": "^3.0.2",
|
"eventsource": "^3.0.2",
|
||||||
"express": "^5.0.1",
|
"express": "^5.0.1",
|
||||||
"express-rate-limit": "^7.5.0",
|
"express-rate-limit": "^7.5.0",
|
||||||
"pkce-challenge": "^4.1.0",
|
"pkce-challenge": "^5.0.0",
|
||||||
"raw-body": "^3.0.0",
|
"raw-body": "^3.0.0",
|
||||||
"zod": "^3.23.8",
|
"zod": "^3.23.8",
|
||||||
"zod-to-json-schema": "^3.24.1"
|
"zod-to-json-schema": "^3.24.1"
|
||||||
@@ -286,13 +287,13 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@playwright/test": {
|
"node_modules/@playwright/test": {
|
||||||
"version": "1.52.0-alpha-1743163434000",
|
"version": "1.53.0-alpha-1746218818000",
|
||||||
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.52.0-alpha-1743163434000.tgz",
|
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.53.0-alpha-1746218818000.tgz",
|
||||||
"integrity": "sha512-4uBgNlJ6hgPtB8DrwQsgoKuVoe7j+nPqudna7CLXWCmmT3LYPMD5aOjGoBkszr+R9NejtKashq/bOi/ny9hsIA==",
|
"integrity": "sha512-J05FD0oOCVbjbp4IjQi5tOPKywchi5EENS9jRjgkA5N9jd/+BaZ3jT8HlLMIgALdk/eLsprQa7vh9h45Q1FOPA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"playwright": "1.52.0-alpha-1743163434000"
|
"playwright": "1.53.0-alpha-1746218818000"
|
||||||
},
|
},
|
||||||
"bin": {
|
"bin": {
|
||||||
"playwright": "cli.js"
|
"playwright": "cli.js"
|
||||||
@@ -1091,7 +1092,6 @@
|
|||||||
"version": "7.0.6",
|
"version": "7.0.6",
|
||||||
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
|
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
|
||||||
"integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==",
|
"integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"path-key": "^3.1.0",
|
"path-key": "^3.1.0",
|
||||||
@@ -2786,7 +2786,6 @@
|
|||||||
"version": "2.0.0",
|
"version": "2.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz",
|
||||||
"integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==",
|
"integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==",
|
||||||
"dev": true,
|
|
||||||
"license": "ISC"
|
"license": "ISC"
|
||||||
},
|
},
|
||||||
"node_modules/js-yaml": {
|
"node_modules/js-yaml": {
|
||||||
@@ -3256,7 +3255,6 @@
|
|||||||
"version": "3.1.1",
|
"version": "3.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz",
|
||||||
"integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==",
|
"integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=8"
|
"node": ">=8"
|
||||||
@@ -3292,21 +3290,21 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/pkce-challenge": {
|
"node_modules/pkce-challenge": {
|
||||||
"version": "4.1.0",
|
"version": "5.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/pkce-challenge/-/pkce-challenge-4.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/pkce-challenge/-/pkce-challenge-5.0.0.tgz",
|
||||||
"integrity": "sha512-ZBmhE1C9LcPoH9XZSdwiPtbPHZROwAnMy+kIFQVrnMCxY4Cudlz3gBOpzilgc0jOgRaiT3sIWfpMomW2ar2orQ==",
|
"integrity": "sha512-ueGLflrrnvwB3xuo/uGob5pd5FN7l0MsLf0Z87o/UQmRtwjvfylfc9MurIxRAWywCYTgrvpXBcqjV4OfCYGCIQ==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=16.20.0"
|
"node": ">=16.20.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/playwright": {
|
"node_modules/playwright": {
|
||||||
"version": "1.52.0-alpha-1743163434000",
|
"version": "1.53.0-alpha-1746218818000",
|
||||||
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.52.0-alpha-1743163434000.tgz",
|
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.53.0-alpha-1746218818000.tgz",
|
||||||
"integrity": "sha512-4uYv49ekPjolydfFfTfFQ2z4URF9UZMVUXLy7aXam/tPxEQ5O7+jQC+yzrDMGmhcj5QkMnxjlyk7N2V9a0QLdQ==",
|
"integrity": "sha512-mVIjtdqIawIqWVyvCaLmV6XTALCT4oWWrbMjoHyyWRln3jQjnm3RUO9LkaINz+Yh88O3FkuY6RfjGXPXeFeJ4Q==",
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"playwright-core": "1.52.0-alpha-1743163434000"
|
"playwright-core": "1.53.0-alpha-1746218818000"
|
||||||
},
|
},
|
||||||
"bin": {
|
"bin": {
|
||||||
"playwright": "cli.js"
|
"playwright": "cli.js"
|
||||||
@@ -3319,9 +3317,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/playwright-core": {
|
"node_modules/playwright-core": {
|
||||||
"version": "1.52.0-alpha-1743163434000",
|
"version": "1.53.0-alpha-1746218818000",
|
||||||
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.52.0-alpha-1743163434000.tgz",
|
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.53.0-alpha-1746218818000.tgz",
|
||||||
"integrity": "sha512-Tn4u3Ywwjkh847/bYWlXIrNxv5DRJRDgtb+VYMXHvNCKkrxL6yfZ1ApIAYD7IAkkKH/KLTXszGWl3a/Z/KDfQA==",
|
"integrity": "sha512-iaIZmhO/psGssWpxIprJkFrn2h4xFjgL0jZsKGtReAMZ/XhlqMUJxtSitwWM4BV+wxJIptsZD0s5Ml2KU62Z3w==",
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"bin": {
|
"bin": {
|
||||||
"playwright-core": "cli.js"
|
"playwright-core": "cli.js"
|
||||||
@@ -3796,7 +3794,6 @@
|
|||||||
"version": "2.0.0",
|
"version": "2.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
|
||||||
"integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==",
|
"integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"shebang-regex": "^3.0.0"
|
"shebang-regex": "^3.0.0"
|
||||||
@@ -3809,7 +3806,6 @@
|
|||||||
"version": "3.0.0",
|
"version": "3.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz",
|
||||||
"integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==",
|
"integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=8"
|
"node": ">=8"
|
||||||
@@ -4238,7 +4234,6 @@
|
|||||||
"version": "2.0.2",
|
"version": "2.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
|
||||||
"integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==",
|
"integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==",
|
||||||
"dev": true,
|
|
||||||
"license": "ISC",
|
"license": "ISC",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"isexe": "^2.0.0"
|
"isexe": "^2.0.0"
|
||||||
|
|||||||
11
package.json
11
package.json
@@ -1,7 +1,8 @@
|
|||||||
{
|
{
|
||||||
"name": "@playwright/mcp",
|
"name": "@playwright/mcp",
|
||||||
"version": "0.0.14",
|
"version": "0.0.19",
|
||||||
"description": "Playwright Tools for MCP",
|
"description": "Playwright Tools for MCP",
|
||||||
|
"type": "module",
|
||||||
"repository": {
|
"repository": {
|
||||||
"type": "git",
|
"type": "git",
|
||||||
"url": "git+https://github.com/microsoft/playwright-mcp.git"
|
"url": "git+https://github.com/microsoft/playwright-mcp.git"
|
||||||
@@ -16,7 +17,7 @@
|
|||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "tsc",
|
"build": "tsc",
|
||||||
"lint": "eslint .",
|
"lint": "npm run update-readme && eslint .",
|
||||||
"update-readme": "node utils/update-readme.js",
|
"update-readme": "node utils/update-readme.js",
|
||||||
"watch": "tsc --watch",
|
"watch": "tsc --watch",
|
||||||
"test": "playwright test",
|
"test": "playwright test",
|
||||||
@@ -34,16 +35,16 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@modelcontextprotocol/sdk": "^1.6.1",
|
"@modelcontextprotocol/sdk": "^1.10.1",
|
||||||
"commander": "^13.1.0",
|
"commander": "^13.1.0",
|
||||||
"playwright": "^1.52.0-alpha-1743163434000",
|
"playwright": "1.53.0-alpha-1746218818000",
|
||||||
"yaml": "^2.7.1",
|
"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.52.0-alpha-1743163434000",
|
"@playwright/test": "1.53.0-alpha-1746218818000",
|
||||||
"@stylistic/eslint-plugin": "^3.0.1",
|
"@stylistic/eslint-plugin": "^3.0.1",
|
||||||
"@types/node": "^22.13.10",
|
"@types/node": "^22.13.10",
|
||||||
"@typescript-eslint/eslint-plugin": "^8.26.1",
|
"@typescript-eslint/eslint-plugin": "^8.26.1",
|
||||||
|
|||||||
@@ -16,9 +16,9 @@
|
|||||||
|
|
||||||
import { defineConfig } from '@playwright/test';
|
import { defineConfig } from '@playwright/test';
|
||||||
|
|
||||||
import type { Project } from '@playwright/test';
|
import type { TestOptions } from './tests/fixtures.js';
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig<TestOptions>({
|
||||||
testDir: './tests',
|
testDir: './tests',
|
||||||
fullyParallel: true,
|
fullyParallel: true,
|
||||||
forbidOnly: !!process.env.CI,
|
forbidOnly: !!process.env.CI,
|
||||||
@@ -31,5 +31,5 @@ export default defineConfig({
|
|||||||
{ name: 'chromium', use: { mcpBrowser: 'chromium' } },
|
{ name: 'chromium', use: { mcpBrowser: 'chromium' } },
|
||||||
{ name: 'firefox', use: { mcpBrowser: 'firefox' } },
|
{ name: 'firefox', use: { mcpBrowser: 'firefox' } },
|
||||||
{ name: 'webkit', use: { mcpBrowser: 'webkit' } },
|
{ name: 'webkit', use: { mcpBrowser: 'webkit' } },
|
||||||
].filter(Boolean) as Project[],
|
],
|
||||||
});
|
});
|
||||||
|
|||||||
189
src/config.ts
Normal file
189
src/config.ts
Normal file
@@ -0,0 +1,189 @@
|
|||||||
|
/**
|
||||||
|
* Copyright (c) Microsoft Corporation.
|
||||||
|
*
|
||||||
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
* you may not use this file except in compliance with the License.
|
||||||
|
* You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing, software
|
||||||
|
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
* See the License for the specific language governing permissions and
|
||||||
|
* limitations under the License.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import fs from 'fs';
|
||||||
|
import net from 'net';
|
||||||
|
import os from 'os';
|
||||||
|
import path from 'path';
|
||||||
|
import { devices } from 'playwright';
|
||||||
|
|
||||||
|
import type { Config, ToolCapability } from '../config.js';
|
||||||
|
import type { BrowserContextOptions, LaunchOptions } from 'playwright';
|
||||||
|
import { sanitizeForFilePath } from './tools/utils.js';
|
||||||
|
|
||||||
|
export type CLIOptions = {
|
||||||
|
browser?: string;
|
||||||
|
caps?: string;
|
||||||
|
cdpEndpoint?: string;
|
||||||
|
executablePath?: string;
|
||||||
|
headless?: boolean;
|
||||||
|
device?: string;
|
||||||
|
userDataDir?: string;
|
||||||
|
port?: number;
|
||||||
|
host?: string;
|
||||||
|
vision?: boolean;
|
||||||
|
config?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const defaultConfig: Config = {
|
||||||
|
browser: {
|
||||||
|
browserName: 'chromium',
|
||||||
|
userDataDir: os.tmpdir(),
|
||||||
|
launchOptions: {
|
||||||
|
channel: 'chrome',
|
||||||
|
headless: os.platform() === 'linux' && !process.env.DISPLAY,
|
||||||
|
},
|
||||||
|
contextOptions: {
|
||||||
|
viewport: null,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function resolveConfig(cliOptions: CLIOptions): Promise<Config> {
|
||||||
|
const config = await loadConfig(cliOptions.config);
|
||||||
|
const cliOverrides = await configFromCLIOptions(cliOptions);
|
||||||
|
return mergeConfig(defaultConfig, mergeConfig(config, cliOverrides));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function configFromCLIOptions(cliOptions: CLIOptions): Promise<Config> {
|
||||||
|
let browserName: 'chromium' | 'firefox' | 'webkit';
|
||||||
|
let channel: string | undefined;
|
||||||
|
switch (cliOptions.browser) {
|
||||||
|
case 'chrome':
|
||||||
|
case 'chrome-beta':
|
||||||
|
case 'chrome-canary':
|
||||||
|
case 'chrome-dev':
|
||||||
|
case 'chromium':
|
||||||
|
case 'msedge':
|
||||||
|
case 'msedge-beta':
|
||||||
|
case 'msedge-canary':
|
||||||
|
case 'msedge-dev':
|
||||||
|
browserName = 'chromium';
|
||||||
|
channel = cliOptions.browser;
|
||||||
|
break;
|
||||||
|
case 'firefox':
|
||||||
|
browserName = 'firefox';
|
||||||
|
break;
|
||||||
|
case 'webkit':
|
||||||
|
browserName = 'webkit';
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
browserName = 'chromium';
|
||||||
|
channel = 'chrome';
|
||||||
|
}
|
||||||
|
|
||||||
|
const launchOptions: LaunchOptions = {
|
||||||
|
channel,
|
||||||
|
executablePath: cliOptions.executablePath,
|
||||||
|
headless: cliOptions.headless,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (browserName === 'chromium')
|
||||||
|
(launchOptions as any).webSocketPort = await findFreePort();
|
||||||
|
|
||||||
|
const contextOptions: BrowserContextOptions | undefined = cliOptions.device ? devices[cliOptions.device] : undefined;
|
||||||
|
|
||||||
|
return {
|
||||||
|
browser: {
|
||||||
|
browserName,
|
||||||
|
userDataDir: cliOptions.userDataDir ?? await createUserDataDir({ browserName, channel }),
|
||||||
|
launchOptions,
|
||||||
|
contextOptions,
|
||||||
|
cdpEndpoint: cliOptions.cdpEndpoint,
|
||||||
|
},
|
||||||
|
server: {
|
||||||
|
port: cliOptions.port,
|
||||||
|
host: cliOptions.host,
|
||||||
|
},
|
||||||
|
capabilities: cliOptions.caps?.split(',').map((c: string) => c.trim() as ToolCapability),
|
||||||
|
vision: !!cliOptions.vision,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function findFreePort() {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const server = net.createServer();
|
||||||
|
server.listen(0, () => {
|
||||||
|
const { port } = server.address() as net.AddressInfo;
|
||||||
|
server.close(() => resolve(port));
|
||||||
|
});
|
||||||
|
server.on('error', reject);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadConfig(configFile: string | undefined): Promise<Config> {
|
||||||
|
if (!configFile)
|
||||||
|
return {};
|
||||||
|
|
||||||
|
try {
|
||||||
|
return JSON.parse(await fs.promises.readFile(configFile, 'utf8'));
|
||||||
|
} catch (error) {
|
||||||
|
throw new Error(`Failed to load config file: ${configFile}, ${error}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createUserDataDir(options: { browserName: 'chromium' | 'firefox' | 'webkit', channel: string | undefined }) {
|
||||||
|
let cacheDirectory: string;
|
||||||
|
if (process.platform === 'linux')
|
||||||
|
cacheDirectory = process.env.XDG_CACHE_HOME || path.join(os.homedir(), '.cache');
|
||||||
|
else if (process.platform === 'darwin')
|
||||||
|
cacheDirectory = path.join(os.homedir(), 'Library', 'Caches');
|
||||||
|
else if (process.platform === 'win32')
|
||||||
|
cacheDirectory = process.env.LOCALAPPDATA || path.join(os.homedir(), 'AppData', 'Local');
|
||||||
|
else
|
||||||
|
throw new Error('Unsupported platform: ' + process.platform);
|
||||||
|
const result = path.join(cacheDirectory, 'ms-playwright', `mcp-${options.channel ?? options.browserName}-profile`);
|
||||||
|
await fs.promises.mkdir(result, { recursive: true });
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function outputFile(config: Config, name: string): Promise<string> {
|
||||||
|
const result = config.outputDir ?? os.tmpdir();
|
||||||
|
await fs.promises.mkdir(result, { recursive: true });
|
||||||
|
const fileName = sanitizeForFilePath(name);
|
||||||
|
return path.join(result, fileName);
|
||||||
|
}
|
||||||
|
|
||||||
|
function pickDefined<T extends object>(obj: T | undefined): Partial<T> {
|
||||||
|
return Object.fromEntries(
|
||||||
|
Object.entries(obj ?? {}).filter(([_, v]) => v !== undefined)
|
||||||
|
) as Partial<T>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function mergeConfig(base: Config, overrides: Config): Config {
|
||||||
|
const browser: Config['browser'] = {
|
||||||
|
...pickDefined(base.browser),
|
||||||
|
...pickDefined(overrides.browser),
|
||||||
|
launchOptions: {
|
||||||
|
...pickDefined(base.browser?.launchOptions),
|
||||||
|
...pickDefined(overrides.browser?.launchOptions),
|
||||||
|
...{ assistantMode: true },
|
||||||
|
},
|
||||||
|
contextOptions: {
|
||||||
|
...pickDefined(base.browser?.contextOptions),
|
||||||
|
...pickDefined(overrides.browser?.contextOptions),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
if (browser.browserName !== 'chromium')
|
||||||
|
delete browser.launchOptions.channel;
|
||||||
|
|
||||||
|
return {
|
||||||
|
...pickDefined(base),
|
||||||
|
...pickDefined(overrides),
|
||||||
|
browser,
|
||||||
|
};
|
||||||
|
}
|
||||||
243
src/context.ts
243
src/context.ts
@@ -15,23 +15,15 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import * as playwright from 'playwright';
|
import * as playwright from 'playwright';
|
||||||
import yaml from 'yaml';
|
|
||||||
|
|
||||||
import { waitForCompletion } from './tools/utils';
|
import { waitForCompletion } from './tools/utils.js';
|
||||||
import { ManualPromise } from './manualPromise';
|
import { ManualPromise } from './manualPromise.js';
|
||||||
|
import { Tab } from './tab.js';
|
||||||
|
|
||||||
import type { ImageContent, TextContent } from '@modelcontextprotocol/sdk/types';
|
import type { ImageContent, TextContent } from '@modelcontextprotocol/sdk/types.js';
|
||||||
import type { ModalState, Tool, ToolActionResult } from './tools/tool';
|
import type { ModalState, Tool, ToolActionResult } from './tools/tool.js';
|
||||||
|
import type { Config } from '../config.js';
|
||||||
export type ContextOptions = {
|
import { outputFile } from './config.js';
|
||||||
browserName?: 'chromium' | 'firefox' | 'webkit';
|
|
||||||
userDataDir: string;
|
|
||||||
launchOptions?: playwright.LaunchOptions;
|
|
||||||
cdpEndpoint?: string;
|
|
||||||
remoteEndpoint?: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
type PageOrFrameLocator = playwright.Page | playwright.FrameLocator;
|
|
||||||
|
|
||||||
type PendingAction = {
|
type PendingAction = {
|
||||||
dialogShown: ManualPromise<void>;
|
dialogShown: ManualPromise<void>;
|
||||||
@@ -39,17 +31,19 @@ type PendingAction = {
|
|||||||
|
|
||||||
export class Context {
|
export class Context {
|
||||||
readonly tools: Tool[];
|
readonly tools: Tool[];
|
||||||
readonly options: ContextOptions;
|
readonly config: Config;
|
||||||
private _browser: playwright.Browser | undefined;
|
private _browser: playwright.Browser | undefined;
|
||||||
private _browserContext: playwright.BrowserContext | undefined;
|
private _browserContext: playwright.BrowserContext | undefined;
|
||||||
|
private _createBrowserContextPromise: Promise<{ browser?: playwright.Browser, browserContext: playwright.BrowserContext }> | undefined;
|
||||||
private _tabs: Tab[] = [];
|
private _tabs: Tab[] = [];
|
||||||
private _currentTab: Tab | undefined;
|
private _currentTab: Tab | undefined;
|
||||||
private _modalStates: (ModalState & { tab: Tab })[] = [];
|
private _modalStates: (ModalState & { tab: Tab })[] = [];
|
||||||
private _pendingAction: PendingAction | undefined;
|
private _pendingAction: PendingAction | undefined;
|
||||||
|
private _downloads: { download: playwright.Download, finished: boolean, outputFile: string }[] = [];
|
||||||
|
|
||||||
constructor(tools: Tool[], options: ContextOptions) {
|
constructor(tools: Tool[], config: Config) {
|
||||||
this.tools = tools;
|
this.tools = tools;
|
||||||
this.options = options;
|
this.config = config;
|
||||||
}
|
}
|
||||||
|
|
||||||
modalStates(): ModalState[] {
|
modalStates(): ModalState[] {
|
||||||
@@ -66,6 +60,8 @@ export class Context {
|
|||||||
|
|
||||||
modalStatesMarkdown(): string[] {
|
modalStatesMarkdown(): string[] {
|
||||||
const result: string[] = ['### Modal state'];
|
const result: string[] = ['### Modal state'];
|
||||||
|
if (this._modalStates.length === 0)
|
||||||
|
result.push('- There is no modal state present');
|
||||||
for (const state of this._modalStates) {
|
for (const state of this._modalStates) {
|
||||||
const tool = this.tools.find(tool => tool.clearsModalState === state.type);
|
const tool = this.tools.find(tool => tool.clearsModalState === state.type);
|
||||||
result.push(`- [${state.description}]: can be handled by the "${tool?.schema.name}" tool`);
|
result.push(`- [${state.description}]: can be handled by the "${tool?.schema.name}" tool`);
|
||||||
@@ -124,7 +120,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, 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;
|
||||||
|
|
||||||
@@ -170,6 +166,17 @@ ${code.join('\n')}
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (this._downloads.length) {
|
||||||
|
result.push('', '### Downloads');
|
||||||
|
for (const entry of this._downloads) {
|
||||||
|
if (entry.finished)
|
||||||
|
result.push(`- Downloaded file ${entry.download.suggestedFilename()} to ${entry.outputFile}`);
|
||||||
|
else
|
||||||
|
result.push(`- Downloading file ${entry.download.suggestedFilename()} ...`);
|
||||||
|
}
|
||||||
|
result.push('');
|
||||||
|
}
|
||||||
|
|
||||||
if (this.tabs().length > 1)
|
if (this.tabs().length > 1)
|
||||||
result.push(await this.listTabsMarkdown(), '');
|
result.push(await this.listTabsMarkdown(), '');
|
||||||
|
|
||||||
@@ -234,6 +241,17 @@ ${code.join('\n')}
|
|||||||
this._pendingAction?.dialogShown.resolve();
|
this._pendingAction?.dialogShown.resolve();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async downloadStarted(tab: Tab, download: playwright.Download) {
|
||||||
|
const entry = {
|
||||||
|
download,
|
||||||
|
finished: false,
|
||||||
|
outputFile: await outputFile(this.config, download.suggestedFilename())
|
||||||
|
};
|
||||||
|
this._downloads.push(entry);
|
||||||
|
await download.saveAs(entry.outputFile);
|
||||||
|
entry.finished = true;
|
||||||
|
}
|
||||||
|
|
||||||
private _onPageCreated(page: playwright.Page) {
|
private _onPageCreated(page: playwright.Page) {
|
||||||
const tab = new Tab(this, page, tab => this._onPageClosed(tab));
|
const tab = new Tab(this, page, tab => this._onPageClosed(tab));
|
||||||
this._tabs.push(tab);
|
this._tabs.push(tab);
|
||||||
@@ -259,6 +277,7 @@ ${code.join('\n')}
|
|||||||
return;
|
return;
|
||||||
const browserContext = this._browserContext;
|
const browserContext = this._browserContext;
|
||||||
const browser = this._browser;
|
const browser = this._browser;
|
||||||
|
this._createBrowserContextPromise = undefined;
|
||||||
this._browserContext = undefined;
|
this._browserContext = undefined;
|
||||||
this._browser = undefined;
|
this._browser = undefined;
|
||||||
|
|
||||||
@@ -280,176 +299,46 @@ ${code.join('\n')}
|
|||||||
}
|
}
|
||||||
|
|
||||||
private async _createBrowserContext(): Promise<{ browser?: playwright.Browser, browserContext: playwright.BrowserContext }> {
|
private async _createBrowserContext(): Promise<{ browser?: playwright.Browser, browserContext: playwright.BrowserContext }> {
|
||||||
if (this.options.remoteEndpoint) {
|
if (!this._createBrowserContextPromise) {
|
||||||
const url = new URL(this.options.remoteEndpoint);
|
this._createBrowserContextPromise = this._innerCreateBrowserContext();
|
||||||
if (this.options.browserName)
|
void this._createBrowserContextPromise.catch(() => {
|
||||||
url.searchParams.set('browser', this.options.browserName);
|
this._createBrowserContextPromise = undefined;
|
||||||
if (this.options.launchOptions)
|
});
|
||||||
url.searchParams.set('launch-options', JSON.stringify(this.options.launchOptions));
|
}
|
||||||
const browser = await playwright[this.options.browserName ?? 'chromium'].connect(String(url));
|
return this._createBrowserContextPromise;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async _innerCreateBrowserContext(): Promise<{ browser?: playwright.Browser, browserContext: playwright.BrowserContext }> {
|
||||||
|
if (this.config.browser?.remoteEndpoint) {
|
||||||
|
const url = new URL(this.config.browser?.remoteEndpoint);
|
||||||
|
if (this.config.browser.browserName)
|
||||||
|
url.searchParams.set('browser', this.config.browser.browserName);
|
||||||
|
if (this.config.browser.launchOptions)
|
||||||
|
url.searchParams.set('launch-options', JSON.stringify(this.config.browser.launchOptions));
|
||||||
|
const browser = await playwright[this.config.browser?.browserName ?? 'chromium'].connect(String(url));
|
||||||
const browserContext = await browser.newContext();
|
const browserContext = await browser.newContext();
|
||||||
return { browser, browserContext };
|
return { browser, browserContext };
|
||||||
}
|
}
|
||||||
|
|
||||||
if (this.options.cdpEndpoint) {
|
if (this.config.browser?.cdpEndpoint) {
|
||||||
const browser = await playwright.chromium.connectOverCDP(this.options.cdpEndpoint);
|
const browser = await playwright.chromium.connectOverCDP(this.config.browser.cdpEndpoint);
|
||||||
const browserContext = browser.contexts()[0];
|
const browserContext = browser.contexts()[0];
|
||||||
return { browser, browserContext };
|
return { browser, browserContext };
|
||||||
}
|
}
|
||||||
|
|
||||||
const browserContext = await this._launchPersistentContext();
|
const browserContext = await launchPersistentContext(this.config.browser);
|
||||||
return { browserContext };
|
return { browserContext };
|
||||||
}
|
}
|
||||||
|
|
||||||
private async _launchPersistentContext(): Promise<playwright.BrowserContext> {
|
|
||||||
try {
|
|
||||||
const browserType = this.options.browserName ? playwright[this.options.browserName] : playwright.chromium;
|
|
||||||
return await browserType.launchPersistentContext(this.options.userDataDir, this.options.launchOptions);
|
|
||||||
} catch (error: any) {
|
|
||||||
if (error.message.includes('Executable doesn\'t exist'))
|
|
||||||
throw new Error(`Browser specified in your config is not installed. Either install it (likely) or change the config.`);
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export class Tab {
|
async function launchPersistentContext(browserConfig: Config['browser']): Promise<playwright.BrowserContext> {
|
||||||
readonly context: Context;
|
try {
|
||||||
readonly page: playwright.Page;
|
const browserType = browserConfig?.browserName ? playwright[browserConfig.browserName] : playwright.chromium;
|
||||||
private _console: playwright.ConsoleMessage[] = [];
|
return await browserType.launchPersistentContext(browserConfig?.userDataDir || '', { ...browserConfig?.launchOptions, ...browserConfig?.contextOptions });
|
||||||
private _snapshot: PageSnapshot | undefined;
|
} catch (error: any) {
|
||||||
private _onPageClose: (tab: Tab) => void;
|
if (error.message.includes('Executable doesn\'t exist'))
|
||||||
|
throw new Error(`Browser specified in your config is not installed. Either install it (likely) or change the config.`);
|
||||||
constructor(context: Context, page: playwright.Page, onPageClose: (tab: Tab) => void) {
|
throw error;
|
||||||
this.context = context;
|
|
||||||
this.page = page;
|
|
||||||
this._onPageClose = onPageClose;
|
|
||||||
page.on('console', event => this._console.push(event));
|
|
||||||
page.on('framenavigated', frame => {
|
|
||||||
if (!frame.parentFrame())
|
|
||||||
this._console.length = 0;
|
|
||||||
});
|
|
||||||
page.on('close', () => this._onClose());
|
|
||||||
page.on('filechooser', chooser => {
|
|
||||||
this.context.setModalState({
|
|
||||||
type: 'fileChooser',
|
|
||||||
description: 'File chooser',
|
|
||||||
fileChooser: chooser,
|
|
||||||
}, this);
|
|
||||||
});
|
|
||||||
page.on('dialog', dialog => this.context.dialogShown(this, dialog));
|
|
||||||
page.setDefaultNavigationTimeout(60000);
|
|
||||||
page.setDefaultTimeout(5000);
|
|
||||||
}
|
|
||||||
|
|
||||||
private _onClose() {
|
|
||||||
this._console.length = 0;
|
|
||||||
this._onPageClose(this);
|
|
||||||
}
|
|
||||||
|
|
||||||
async navigate(url: string) {
|
|
||||||
await this.page.goto(url, { waitUntil: 'domcontentloaded' });
|
|
||||||
// Cap load event to 5 seconds, the page is operational at this point.
|
|
||||||
await this.page.waitForLoadState('load', { timeout: 5000 }).catch(() => {});
|
|
||||||
}
|
|
||||||
|
|
||||||
hasSnapshot(): boolean {
|
|
||||||
return !!this._snapshot;
|
|
||||||
}
|
|
||||||
|
|
||||||
snapshotOrDie(): PageSnapshot {
|
|
||||||
if (!this._snapshot)
|
|
||||||
throw new Error('No snapshot available');
|
|
||||||
return this._snapshot;
|
|
||||||
}
|
|
||||||
|
|
||||||
async console(): Promise<playwright.ConsoleMessage[]> {
|
|
||||||
return this._console;
|
|
||||||
}
|
|
||||||
|
|
||||||
async captureSnapshot() {
|
|
||||||
this._snapshot = await PageSnapshot.create(this.page);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class PageSnapshot {
|
|
||||||
private _frameLocators: PageOrFrameLocator[] = [];
|
|
||||||
private _text!: string;
|
|
||||||
|
|
||||||
constructor() {
|
|
||||||
}
|
|
||||||
|
|
||||||
static async create(page: playwright.Page): Promise<PageSnapshot> {
|
|
||||||
const snapshot = new PageSnapshot();
|
|
||||||
await snapshot._build(page);
|
|
||||||
return snapshot;
|
|
||||||
}
|
|
||||||
|
|
||||||
text(): string {
|
|
||||||
return this._text;
|
|
||||||
}
|
|
||||||
|
|
||||||
private async _build(page: playwright.Page) {
|
|
||||||
const yamlDocument = await this._snapshotFrame(page);
|
|
||||||
this._text = [
|
|
||||||
`- Page Snapshot`,
|
|
||||||
'```yaml',
|
|
||||||
yamlDocument.toString({ indentSeq: false }).trim(),
|
|
||||||
'```',
|
|
||||||
].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 });
|
|
||||||
const snapshot = yaml.parseDocument(snapshotString);
|
|
||||||
|
|
||||||
const visit = async (node: any): Promise<unknown> => {
|
|
||||||
if (yaml.isPair(node)) {
|
|
||||||
await Promise.all([
|
|
||||||
visit(node.key).then(k => node.key = k),
|
|
||||||
visit(node.value).then(v => node.value = v)
|
|
||||||
]);
|
|
||||||
} else if (yaml.isSeq(node) || yaml.isMap(node)) {
|
|
||||||
node.items = await Promise.all(node.items.map(visit));
|
|
||||||
} else if (yaml.isScalar(node)) {
|
|
||||||
if (typeof node.value === 'string') {
|
|
||||||
const value = node.value;
|
|
||||||
if (frameIndex > 0)
|
|
||||||
node.value = value.replace('[ref=', `[ref=f${frameIndex}`);
|
|
||||||
if (value.startsWith('iframe ')) {
|
|
||||||
const ref = value.match(/\[ref=(.*)\]/)?.[1];
|
|
||||||
if (ref) {
|
|
||||||
try {
|
|
||||||
const childSnapshot = await this._snapshotFrame(frame.frameLocator(`aria-ref=${ref}`));
|
|
||||||
return snapshot.createPair(node.value, childSnapshot);
|
|
||||||
} catch (error) {
|
|
||||||
return snapshot.createPair(node.value, '<could not take iframe snapshot>');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return node;
|
|
||||||
};
|
|
||||||
await visit(snapshot.contents);
|
|
||||||
return snapshot;
|
|
||||||
}
|
|
||||||
|
|
||||||
refLocator(ref: string): playwright.Locator {
|
|
||||||
let frame = this._frameLocators[0];
|
|
||||||
const match = ref.match(/^f(\d+)(.*)/);
|
|
||||||
if (match) {
|
|
||||||
const frameIndex = parseInt(match[1], 10);
|
|
||||||
frame = this._frameLocators[frameIndex];
|
|
||||||
ref = match[2];
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!frame)
|
|
||||||
throw new Error(`Frame does not exist. Provide ref from the most current snapshot.`);
|
|
||||||
|
|
||||||
return frame.locator(`aria-ref=${ref}`);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
118
src/index.ts
118
src/index.ts
@@ -14,28 +14,25 @@
|
|||||||
* limitations under the License.
|
* limitations under the License.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import path from 'path';
|
import { createServerWithTools } from './server.js';
|
||||||
import os from 'os';
|
import common from './tools/common.js';
|
||||||
import fs from 'fs';
|
import console from './tools/console.js';
|
||||||
|
import dialogs from './tools/dialogs.js';
|
||||||
import { createServerWithTools } from './server';
|
import files from './tools/files.js';
|
||||||
import common from './tools/common';
|
import install from './tools/install.js';
|
||||||
import console from './tools/console';
|
import keyboard from './tools/keyboard.js';
|
||||||
import dialogs from './tools/dialogs';
|
import navigate from './tools/navigate.js';
|
||||||
import files from './tools/files';
|
import network from './tools/network.js';
|
||||||
import install from './tools/install';
|
import pdf from './tools/pdf.js';
|
||||||
import keyboard from './tools/keyboard';
|
import snapshot from './tools/snapshot.js';
|
||||||
import navigate from './tools/navigate';
|
import tabs from './tools/tabs.js';
|
||||||
import pdf from './tools/pdf';
|
import screen from './tools/screen.js';
|
||||||
import snapshot from './tools/snapshot';
|
import testing from './tools/testing.js';
|
||||||
import tabs from './tools/tabs';
|
import type { Tool } from './tools/tool.js';
|
||||||
import screen from './tools/screen';
|
import type { Config } from '../config.js';
|
||||||
|
|
||||||
import type { Tool, ToolCapability } from './tools/tool';
|
|
||||||
import type { Server } from '@modelcontextprotocol/sdk/server/index.js';
|
import type { Server } from '@modelcontextprotocol/sdk/server/index.js';
|
||||||
import type { LaunchOptions } from 'playwright';
|
|
||||||
|
|
||||||
const snapshotTools: Tool[] = [
|
const snapshotTools: Tool<any>[] = [
|
||||||
...common(true),
|
...common(true),
|
||||||
...console,
|
...console,
|
||||||
...dialogs(true),
|
...dialogs(true),
|
||||||
@@ -43,12 +40,14 @@ const snapshotTools: Tool[] = [
|
|||||||
...install,
|
...install,
|
||||||
...keyboard(true),
|
...keyboard(true),
|
||||||
...navigate(true),
|
...navigate(true),
|
||||||
|
...network,
|
||||||
...pdf,
|
...pdf,
|
||||||
...snapshot,
|
...snapshot,
|
||||||
...tabs(true),
|
...tabs(true),
|
||||||
|
...testing,
|
||||||
];
|
];
|
||||||
|
|
||||||
const screenshotTools: Tool[] = [
|
const screenshotTools: Tool<any>[] = [
|
||||||
...common(false),
|
...common(false),
|
||||||
...console,
|
...console,
|
||||||
...dialogs(false),
|
...dialogs(false),
|
||||||
@@ -56,84 +55,21 @@ const screenshotTools: Tool[] = [
|
|||||||
...install,
|
...install,
|
||||||
...keyboard(false),
|
...keyboard(false),
|
||||||
...navigate(false),
|
...navigate(false),
|
||||||
|
...network,
|
||||||
...pdf,
|
...pdf,
|
||||||
...screen,
|
...screen,
|
||||||
...tabs(false),
|
...tabs(false),
|
||||||
|
...testing,
|
||||||
];
|
];
|
||||||
|
|
||||||
type Options = {
|
import packageJSON from '../package.json' with { type: 'json' };
|
||||||
browser?: string;
|
|
||||||
userDataDir?: string;
|
|
||||||
headless?: boolean;
|
|
||||||
executablePath?: string;
|
|
||||||
cdpEndpoint?: string;
|
|
||||||
vision?: boolean;
|
|
||||||
capabilities?: ToolCapability[];
|
|
||||||
};
|
|
||||||
|
|
||||||
const packageJSON = require('../package.json');
|
export async function createServer(config: Config = {}): Promise<Server> {
|
||||||
|
const allTools = config.vision ? screenshotTools : snapshotTools;
|
||||||
export async function createServer(options?: Options): Promise<Server> {
|
const tools = allTools.filter(tool => !config.capabilities || tool.capability === 'core' || config.capabilities.includes(tool.capability));
|
||||||
let browserName: 'chromium' | 'firefox' | 'webkit';
|
|
||||||
let channel: string | undefined;
|
|
||||||
switch (options?.browser) {
|
|
||||||
case 'chrome':
|
|
||||||
case 'chrome-beta':
|
|
||||||
case 'chrome-canary':
|
|
||||||
case 'chrome-dev':
|
|
||||||
case 'msedge':
|
|
||||||
case 'msedge-beta':
|
|
||||||
case 'msedge-canary':
|
|
||||||
case 'msedge-dev':
|
|
||||||
browserName = 'chromium';
|
|
||||||
channel = options.browser;
|
|
||||||
break;
|
|
||||||
case 'chromium':
|
|
||||||
browserName = 'chromium';
|
|
||||||
break;
|
|
||||||
case 'firefox':
|
|
||||||
browserName = 'firefox';
|
|
||||||
break;
|
|
||||||
case 'webkit':
|
|
||||||
browserName = 'webkit';
|
|
||||||
break;
|
|
||||||
default:
|
|
||||||
browserName = 'chromium';
|
|
||||||
channel = 'chrome';
|
|
||||||
}
|
|
||||||
const userDataDir = options?.userDataDir ?? await createUserDataDir(browserName);
|
|
||||||
|
|
||||||
const launchOptions: LaunchOptions = {
|
|
||||||
headless: !!(options?.headless ?? (os.platform() === 'linux' && !process.env.DISPLAY)),
|
|
||||||
channel,
|
|
||||||
executablePath: options?.executablePath,
|
|
||||||
};
|
|
||||||
|
|
||||||
const allTools = options?.vision ? screenshotTools : snapshotTools;
|
|
||||||
const tools = allTools.filter(tool => !options?.capabilities || tool.capability === 'core' || options.capabilities.includes(tool.capability));
|
|
||||||
return createServerWithTools({
|
return createServerWithTools({
|
||||||
name: 'Playwright',
|
name: 'Playwright',
|
||||||
version: packageJSON.version,
|
version: packageJSON.version,
|
||||||
tools,
|
tools,
|
||||||
resources: [],
|
}, config);
|
||||||
browserName,
|
|
||||||
userDataDir,
|
|
||||||
launchOptions,
|
|
||||||
cdpEndpoint: options?.cdpEndpoint,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async function createUserDataDir(browserName: 'chromium' | 'firefox' | 'webkit') {
|
|
||||||
let cacheDirectory: string;
|
|
||||||
if (process.platform === 'linux')
|
|
||||||
cacheDirectory = process.env.XDG_CACHE_HOME || path.join(os.homedir(), '.cache');
|
|
||||||
else if (process.platform === 'darwin')
|
|
||||||
cacheDirectory = path.join(os.homedir(), 'Library', 'Caches');
|
|
||||||
else if (process.platform === 'win32')
|
|
||||||
cacheDirectory = process.env.LOCALAPPDATA || path.join(os.homedir(), 'AppData', 'Local');
|
|
||||||
else
|
|
||||||
throw new Error('Unsupported platform: ' + process.platform);
|
|
||||||
const result = path.join(cacheDirectory, 'ms-playwright', `mcp-${browserName}-profile`);
|
|
||||||
await fs.promises.mkdir(result, { recursive: true });
|
|
||||||
return result;
|
|
||||||
}
|
}
|
||||||
|
|||||||
101
src/pageSnapshot.ts
Normal file
101
src/pageSnapshot.ts
Normal file
@@ -0,0 +1,101 @@
|
|||||||
|
/**
|
||||||
|
* Copyright (c) Microsoft Corporation.
|
||||||
|
*
|
||||||
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
* you may not use this file except in compliance with the License.
|
||||||
|
* You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing, software
|
||||||
|
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
* See the License for the specific language governing permissions and
|
||||||
|
* limitations under the License.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import * as playwright from 'playwright';
|
||||||
|
import yaml from 'yaml';
|
||||||
|
|
||||||
|
type PageOrFrameLocator = playwright.Page | playwright.FrameLocator;
|
||||||
|
|
||||||
|
export class PageSnapshot {
|
||||||
|
private _frameLocators: PageOrFrameLocator[] = [];
|
||||||
|
private _text!: string;
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
}
|
||||||
|
|
||||||
|
static async create(page: playwright.Page): Promise<PageSnapshot> {
|
||||||
|
const snapshot = new PageSnapshot();
|
||||||
|
await snapshot._build(page);
|
||||||
|
return snapshot;
|
||||||
|
}
|
||||||
|
|
||||||
|
text(): string {
|
||||||
|
return this._text;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async _build(page: playwright.Page) {
|
||||||
|
const yamlDocument = await this._snapshotFrame(page);
|
||||||
|
this._text = [
|
||||||
|
`- Page Snapshot`,
|
||||||
|
'```yaml',
|
||||||
|
yamlDocument.toString({ indentSeq: false }).trim(),
|
||||||
|
'```',
|
||||||
|
].join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
private async _snapshotFrame(frame: playwright.Page | playwright.FrameLocator) {
|
||||||
|
const frameIndex = this._frameLocators.push(frame) - 1;
|
||||||
|
const snapshotString = await frame.locator('body').ariaSnapshot({ ref: true, emitGeneric: true });
|
||||||
|
const snapshot = yaml.parseDocument(snapshotString);
|
||||||
|
|
||||||
|
const visit = async (node: any): Promise<unknown> => {
|
||||||
|
if (yaml.isPair(node)) {
|
||||||
|
await Promise.all([
|
||||||
|
visit(node.key).then(k => node.key = k),
|
||||||
|
visit(node.value).then(v => node.value = v)
|
||||||
|
]);
|
||||||
|
} else if (yaml.isSeq(node) || yaml.isMap(node)) {
|
||||||
|
node.items = await Promise.all(node.items.map(visit));
|
||||||
|
} else if (yaml.isScalar(node)) {
|
||||||
|
if (typeof node.value === 'string') {
|
||||||
|
const value = node.value;
|
||||||
|
if (frameIndex > 0)
|
||||||
|
node.value = value.replace('[ref=', `[ref=f${frameIndex}`);
|
||||||
|
if (value.startsWith('iframe ')) {
|
||||||
|
const ref = value.match(/\[ref=(.*)\]/)?.[1];
|
||||||
|
if (ref) {
|
||||||
|
try {
|
||||||
|
const childSnapshot = await this._snapshotFrame(frame.frameLocator(`aria-ref=${ref}`));
|
||||||
|
return snapshot.createPair(node.value, childSnapshot);
|
||||||
|
} catch (error) {
|
||||||
|
return snapshot.createPair(node.value, '<could not take iframe snapshot>');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return node;
|
||||||
|
};
|
||||||
|
await visit(snapshot.contents);
|
||||||
|
return snapshot;
|
||||||
|
}
|
||||||
|
|
||||||
|
refLocator(ref: string): playwright.Locator {
|
||||||
|
let frame = this._frameLocators[0];
|
||||||
|
const match = ref.match(/^f(\d+)(.*)/);
|
||||||
|
if (match) {
|
||||||
|
const frameIndex = parseInt(match[1], 10);
|
||||||
|
frame = this._frameLocators[frameIndex];
|
||||||
|
ref = match[2];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!frame)
|
||||||
|
throw new Error(`Frame does not exist. Provide ref from the most current snapshot.`);
|
||||||
|
|
||||||
|
return frame.locator(`aria-ref=${ref}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
101
src/program.ts
101
src/program.ts
@@ -14,20 +14,16 @@
|
|||||||
* limitations under the License.
|
* limitations under the License.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import http from 'http';
|
|
||||||
|
|
||||||
import { program } from 'commander';
|
import { program } from 'commander';
|
||||||
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
|
||||||
import { SSEServerTransport } from '@modelcontextprotocol/sdk/server/sse.js';
|
|
||||||
|
|
||||||
|
import { createServer } from './index.js';
|
||||||
|
import { ServerList } from './server.js';
|
||||||
|
|
||||||
import { createServer } from './index';
|
import { startHttpTransport, startStdioTransport } from './transport.js';
|
||||||
import { ServerList } from './server';
|
|
||||||
|
|
||||||
import assert from 'assert';
|
import { resolveConfig } from './config.js';
|
||||||
import { ToolCapability } from './tools/tool';
|
|
||||||
|
|
||||||
const packageJSON = require('../package.json');
|
import packageJSON from '../package.json' with { type: 'json' };
|
||||||
|
|
||||||
program
|
program
|
||||||
.version('Version ' + packageJSON.version)
|
.version('Version ' + packageJSON.version)
|
||||||
@@ -37,27 +33,21 @@ program
|
|||||||
.option('--cdp-endpoint <endpoint>', 'CDP endpoint to connect to.')
|
.option('--cdp-endpoint <endpoint>', 'CDP endpoint to connect to.')
|
||||||
.option('--executable-path <path>', 'Path to the browser executable.')
|
.option('--executable-path <path>', 'Path to the browser executable.')
|
||||||
.option('--headless', 'Run browser in headless mode, headed by default')
|
.option('--headless', 'Run browser in headless mode, headed by default')
|
||||||
.option('--port <port>', 'Port to listen on for SSE transport.')
|
.option('--device <device>', 'Device to emulate, for example: "iPhone 15"')
|
||||||
.option('--user-data-dir <path>', 'Path to the user data directory')
|
.option('--user-data-dir <path>', 'Path to the user data directory')
|
||||||
|
.option('--port <port>', 'Port to listen on for SSE transport.')
|
||||||
|
.option('--host <host>', 'Host to bind server to. Default is localhost. Use 0.0.0.0 to bind to all interfaces.')
|
||||||
.option('--vision', 'Run server that uses screenshots (Aria snapshots are used by default)')
|
.option('--vision', 'Run server that uses screenshots (Aria snapshots are used by default)')
|
||||||
|
.option('--config <path>', 'Path to the configuration file.')
|
||||||
.action(async options => {
|
.action(async options => {
|
||||||
const serverList = new ServerList(() => createServer({
|
const config = await resolveConfig(options);
|
||||||
browser: options.browser,
|
const serverList = new ServerList(() => createServer(config));
|
||||||
userDataDir: options.userDataDir,
|
|
||||||
headless: options.headless,
|
|
||||||
executablePath: options.executablePath,
|
|
||||||
vision: !!options.vision,
|
|
||||||
cdpEndpoint: options.cdpEndpoint,
|
|
||||||
capabilities: options.caps?.split(',').map((c: string) => c.trim() as ToolCapability),
|
|
||||||
}));
|
|
||||||
setupExitWatchdog(serverList);
|
setupExitWatchdog(serverList);
|
||||||
|
|
||||||
if (options.port) {
|
if (options.port)
|
||||||
startSSEServer(+options.port, serverList);
|
startHttpTransport(+options.port, options.host, serverList);
|
||||||
} else {
|
else
|
||||||
const server = await serverList.create();
|
await startStdioTransport(serverList);
|
||||||
await server.connect(new StdioServerTransport());
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
function setupExitWatchdog(serverList: ServerList) {
|
function setupExitWatchdog(serverList: ServerList) {
|
||||||
@@ -73,64 +63,3 @@ function setupExitWatchdog(serverList: ServerList) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
program.parse(process.argv);
|
program.parse(process.argv);
|
||||||
|
|
||||||
async function startSSEServer(port: number, serverList: ServerList) {
|
|
||||||
const sessions = new Map<string, SSEServerTransport>();
|
|
||||||
const httpServer = http.createServer(async (req, res) => {
|
|
||||||
if (req.method === 'POST') {
|
|
||||||
const searchParams = new URL(`http://localhost${req.url}`).searchParams;
|
|
||||||
const sessionId = searchParams.get('sessionId');
|
|
||||||
if (!sessionId) {
|
|
||||||
res.statusCode = 400;
|
|
||||||
res.end('Missing sessionId');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const transport = sessions.get(sessionId);
|
|
||||||
if (!transport) {
|
|
||||||
res.statusCode = 404;
|
|
||||||
res.end('Session not found');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
await transport.handlePostMessage(req, res);
|
|
||||||
return;
|
|
||||||
} else if (req.method === 'GET') {
|
|
||||||
const transport = new SSEServerTransport('/sse', res);
|
|
||||||
sessions.set(transport.sessionId, transport);
|
|
||||||
const server = await serverList.create();
|
|
||||||
res.on('close', () => {
|
|
||||||
sessions.delete(transport.sessionId);
|
|
||||||
serverList.close(server).catch(e => console.error(e));
|
|
||||||
});
|
|
||||||
await server.connect(transport);
|
|
||||||
return;
|
|
||||||
} else {
|
|
||||||
res.statusCode = 405;
|
|
||||||
res.end('Method not allowed');
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
httpServer.listen(port, () => {
|
|
||||||
const address = httpServer.address();
|
|
||||||
assert(address, 'Could not bind server socket');
|
|
||||||
let url: string;
|
|
||||||
if (typeof address === 'string') {
|
|
||||||
url = address;
|
|
||||||
} else {
|
|
||||||
const resolvedPort = address.port;
|
|
||||||
let resolvedHost = address.family === 'IPv4' ? address.address : `[${address.address}]`;
|
|
||||||
if (resolvedHost === '0.0.0.0' || resolvedHost === '[::]')
|
|
||||||
resolvedHost = 'localhost';
|
|
||||||
url = `http://${resolvedHost}:${resolvedPort}`;
|
|
||||||
}
|
|
||||||
console.log(`Listening on ${url}`);
|
|
||||||
console.log('Put this in your client config:');
|
|
||||||
console.log(JSON.stringify({
|
|
||||||
'mcpServers': {
|
|
||||||
'playwright': {
|
|
||||||
'url': `${url}/sse`
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}, undefined, 2));
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -14,7 +14,7 @@
|
|||||||
* limitations under the License.
|
* limitations under the License.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import type { Context } from '../context';
|
import type { Context } from '../context.js';
|
||||||
|
|
||||||
export type ResourceSchema = {
|
export type ResourceSchema = {
|
||||||
uri: string;
|
uri: string;
|
||||||
|
|||||||
@@ -15,80 +15,62 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { Server } from '@modelcontextprotocol/sdk/server/index.js';
|
import { Server } from '@modelcontextprotocol/sdk/server/index.js';
|
||||||
import { CallToolRequestSchema, ListResourcesRequestSchema, ListToolsRequestSchema, ReadResourceRequestSchema } from '@modelcontextprotocol/sdk/types.js';
|
import { CallToolRequestSchema, ListToolsRequestSchema } from '@modelcontextprotocol/sdk/types.js';
|
||||||
|
import { zodToJsonSchema } from 'zod-to-json-schema';
|
||||||
|
|
||||||
import { Context } from './context';
|
import { Context } from './context.js';
|
||||||
|
|
||||||
import type { Tool } from './tools/tool';
|
import type { Tool } from './tools/tool.js';
|
||||||
import type { Resource } from './resources/resource';
|
import type { Config } from '../config.js';
|
||||||
import type { ContextOptions } from './context';
|
|
||||||
|
|
||||||
type Options = ContextOptions & {
|
type MCPServerOptions = {
|
||||||
name: string;
|
name: string;
|
||||||
version: string;
|
version: string;
|
||||||
tools: Tool[];
|
tools: Tool[];
|
||||||
resources: Resource[],
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export function createServerWithTools(options: Options): Server {
|
export function createServerWithTools(serverOptions: MCPServerOptions, config: Config): Server {
|
||||||
const { name, version, tools, resources } = options;
|
const { name, version, tools } = serverOptions;
|
||||||
const context = new Context(tools, options);
|
const context = new Context(tools, config);
|
||||||
const server = new Server({ name, version }, {
|
const server = new Server({ name, version }, {
|
||||||
capabilities: {
|
capabilities: {
|
||||||
tools: {},
|
tools: {},
|
||||||
resources: {},
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
server.setRequestHandler(ListToolsRequestSchema, async () => {
|
server.setRequestHandler(ListToolsRequestSchema, async () => {
|
||||||
return { tools: tools.map(tool => tool.schema) };
|
return {
|
||||||
});
|
tools: tools.map(tool => ({
|
||||||
|
name: tool.schema.name,
|
||||||
server.setRequestHandler(ListResourcesRequestSchema, async () => {
|
description: tool.schema.description,
|
||||||
return { resources: resources.map(resource => resource.schema) };
|
inputSchema: zodToJsonSchema(tool.schema.inputSchema)
|
||||||
|
})),
|
||||||
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
server.setRequestHandler(CallToolRequestSchema, async request => {
|
server.setRequestHandler(CallToolRequestSchema, async request => {
|
||||||
|
const errorResult = (...messages: string[]) => ({
|
||||||
|
content: [{ type: 'text', text: messages.join('\n') }],
|
||||||
|
isError: true,
|
||||||
|
});
|
||||||
const tool = tools.find(tool => tool.schema.name === request.params.name);
|
const tool = tools.find(tool => tool.schema.name === request.params.name);
|
||||||
if (!tool) {
|
if (!tool)
|
||||||
return {
|
return errorResult(`Tool "${request.params.name}" not found`);
|
||||||
content: [{ type: 'text', text: `Tool "${request.params.name}" not found` }],
|
|
||||||
isError: true,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const modalStates = context.modalStates().map(state => state.type);
|
const modalStates = context.modalStates().map(state => state.type);
|
||||||
if ((tool.clearsModalState && !modalStates.includes(tool.clearsModalState)) ||
|
if (tool.clearsModalState && !modalStates.includes(tool.clearsModalState))
|
||||||
(!tool.clearsModalState && modalStates.length)) {
|
return errorResult(`The tool "${request.params.name}" can only be used when there is related modal state present.`, ...context.modalStatesMarkdown());
|
||||||
const text = [
|
if (!tool.clearsModalState && modalStates.length)
|
||||||
`Tool "${request.params.name}" does not handle the modal state.`,
|
return errorResult(`Tool "${request.params.name}" does not handle the modal state.`, ...context.modalStatesMarkdown());
|
||||||
...context.modalStatesMarkdown(),
|
|
||||||
].join('\n');
|
|
||||||
return {
|
|
||||||
content: [{ type: 'text', text }],
|
|
||||||
isError: true,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
return await context.run(tool, request.params.arguments);
|
return await context.run(tool, request.params.arguments);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return {
|
return errorResult(String(error));
|
||||||
content: [{ type: 'text', text: String(error) }],
|
|
||||||
isError: true,
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
server.setRequestHandler(ReadResourceRequestSchema, async request => {
|
|
||||||
const resource = resources.find(resource => resource.schema.uri === request.params.uri);
|
|
||||||
if (!resource)
|
|
||||||
return { contents: [] };
|
|
||||||
|
|
||||||
const contents = await resource.read(context, request.params.uri);
|
|
||||||
return { contents };
|
|
||||||
});
|
|
||||||
|
|
||||||
const oldClose = server.close.bind(server);
|
const oldClose = server.close.bind(server);
|
||||||
|
|
||||||
server.close = async () => {
|
server.close = async () => {
|
||||||
|
|||||||
95
src/tab.ts
Normal file
95
src/tab.ts
Normal file
@@ -0,0 +1,95 @@
|
|||||||
|
/**
|
||||||
|
* Copyright (c) Microsoft Corporation.
|
||||||
|
*
|
||||||
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
* you may not use this file except in compliance with the License.
|
||||||
|
* You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing, software
|
||||||
|
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
* See the License for the specific language governing permissions and
|
||||||
|
* limitations under the License.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import * as playwright from 'playwright';
|
||||||
|
|
||||||
|
import { PageSnapshot } from './pageSnapshot.js';
|
||||||
|
|
||||||
|
import type { Context } from './context.js';
|
||||||
|
|
||||||
|
export class Tab {
|
||||||
|
readonly context: Context;
|
||||||
|
readonly page: playwright.Page;
|
||||||
|
private _console: playwright.ConsoleMessage[] = [];
|
||||||
|
private _requests: Map<playwright.Request, playwright.Response | null> = new Map();
|
||||||
|
private _snapshot: PageSnapshot | undefined;
|
||||||
|
private _onPageClose: (tab: Tab) => void;
|
||||||
|
|
||||||
|
constructor(context: Context, page: playwright.Page, onPageClose: (tab: Tab) => void) {
|
||||||
|
this.context = context;
|
||||||
|
this.page = page;
|
||||||
|
this._onPageClose = onPageClose;
|
||||||
|
page.on('console', event => this._console.push(event));
|
||||||
|
page.on('request', request => this._requests.set(request, null));
|
||||||
|
page.on('response', response => this._requests.set(response.request(), response));
|
||||||
|
page.on('framenavigated', frame => {
|
||||||
|
if (!frame.parentFrame())
|
||||||
|
this._clearCollectedArtifacts();
|
||||||
|
});
|
||||||
|
page.on('close', () => this._onClose());
|
||||||
|
page.on('filechooser', chooser => {
|
||||||
|
this.context.setModalState({
|
||||||
|
type: 'fileChooser',
|
||||||
|
description: 'File chooser',
|
||||||
|
fileChooser: chooser,
|
||||||
|
}, this);
|
||||||
|
});
|
||||||
|
page.on('dialog', dialog => this.context.dialogShown(this, dialog));
|
||||||
|
page.on('download', download => {
|
||||||
|
void this.context.downloadStarted(this, download);
|
||||||
|
});
|
||||||
|
page.setDefaultNavigationTimeout(60000);
|
||||||
|
page.setDefaultTimeout(5000);
|
||||||
|
}
|
||||||
|
|
||||||
|
private _clearCollectedArtifacts() {
|
||||||
|
this._console.length = 0;
|
||||||
|
this._requests.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
private _onClose() {
|
||||||
|
this._clearCollectedArtifacts();
|
||||||
|
this._onPageClose(this);
|
||||||
|
}
|
||||||
|
|
||||||
|
async navigate(url: string) {
|
||||||
|
await this.page.goto(url, { waitUntil: 'domcontentloaded' });
|
||||||
|
// Cap load event to 5 seconds, the page is operational at this point.
|
||||||
|
await this.page.waitForLoadState('load', { timeout: 5000 }).catch(() => {});
|
||||||
|
}
|
||||||
|
|
||||||
|
hasSnapshot(): boolean {
|
||||||
|
return !!this._snapshot;
|
||||||
|
}
|
||||||
|
|
||||||
|
snapshotOrDie(): PageSnapshot {
|
||||||
|
if (!this._snapshot)
|
||||||
|
throw new Error('No snapshot available');
|
||||||
|
return this._snapshot;
|
||||||
|
}
|
||||||
|
|
||||||
|
console(): playwright.ConsoleMessage[] {
|
||||||
|
return this._console;
|
||||||
|
}
|
||||||
|
|
||||||
|
requests(): Map<playwright.Request, playwright.Response | null> {
|
||||||
|
return this._requests;
|
||||||
|
}
|
||||||
|
|
||||||
|
async captureSnapshot() {
|
||||||
|
this._snapshot = await PageSnapshot.create(this.page);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -15,43 +15,36 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
import { zodToJsonSchema } from 'zod-to-json-schema';
|
import { defineTool, type ToolFactory } from './tool.js';
|
||||||
|
|
||||||
import type { Tool, ToolFactory } from './tool';
|
const wait: ToolFactory = captureSnapshot => defineTool({
|
||||||
|
|
||||||
const waitSchema = z.object({
|
|
||||||
time: z.number().describe('The time to wait in seconds'),
|
|
||||||
});
|
|
||||||
|
|
||||||
const wait: ToolFactory = captureSnapshot => ({
|
|
||||||
capability: 'wait',
|
capability: 'wait',
|
||||||
|
|
||||||
schema: {
|
schema: {
|
||||||
name: 'browser_wait',
|
name: 'browser_wait',
|
||||||
description: 'Wait for a specified time in seconds',
|
description: 'Wait for a specified time in seconds',
|
||||||
inputSchema: zodToJsonSchema(waitSchema),
|
inputSchema: z.object({
|
||||||
|
time: z.number().describe('The time to wait in seconds'),
|
||||||
|
}),
|
||||||
},
|
},
|
||||||
|
|
||||||
handle: async (context, params) => {
|
handle: async (context, params) => {
|
||||||
const validatedParams = waitSchema.parse(params);
|
await new Promise(f => setTimeout(f, Math.min(10000, params.time * 1000)));
|
||||||
await new Promise(f => setTimeout(f, Math.min(10000, validatedParams.time * 1000)));
|
|
||||||
return {
|
return {
|
||||||
code: [`// Waited for ${validatedParams.time} seconds`],
|
code: [`// Waited for ${params.time} seconds`],
|
||||||
captureSnapshot,
|
captureSnapshot,
|
||||||
waitForNetwork: false,
|
waitForNetwork: false,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const closeSchema = z.object({});
|
const close = defineTool({
|
||||||
|
|
||||||
const close: Tool = {
|
|
||||||
capability: 'core',
|
capability: 'core',
|
||||||
|
|
||||||
schema: {
|
schema: {
|
||||||
name: 'browser_close',
|
name: 'browser_close',
|
||||||
description: 'Close the page',
|
description: 'Close the page',
|
||||||
inputSchema: zodToJsonSchema(closeSchema),
|
inputSchema: z.object({}),
|
||||||
},
|
},
|
||||||
|
|
||||||
handle: async context => {
|
handle: async context => {
|
||||||
@@ -62,33 +55,29 @@ const close: Tool = {
|
|||||||
waitForNetwork: false,
|
waitForNetwork: false,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
};
|
|
||||||
|
|
||||||
const resizeSchema = z.object({
|
|
||||||
width: z.number().describe('Width of the browser window'),
|
|
||||||
height: z.number().describe('Height of the browser window'),
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const resize: ToolFactory = captureSnapshot => ({
|
const resize: ToolFactory = captureSnapshot => defineTool({
|
||||||
capability: 'core',
|
capability: 'core',
|
||||||
schema: {
|
schema: {
|
||||||
name: 'browser_resize',
|
name: 'browser_resize',
|
||||||
description: 'Resize the browser window',
|
description: 'Resize the browser window',
|
||||||
inputSchema: zodToJsonSchema(resizeSchema),
|
inputSchema: z.object({
|
||||||
|
width: z.number().describe('Width of the browser window'),
|
||||||
|
height: z.number().describe('Height of the browser window'),
|
||||||
|
}),
|
||||||
},
|
},
|
||||||
|
|
||||||
handle: async (context, params) => {
|
handle: async (context, params) => {
|
||||||
const validatedParams = resizeSchema.parse(params);
|
|
||||||
|
|
||||||
const tab = context.currentTabOrDie();
|
const tab = context.currentTabOrDie();
|
||||||
|
|
||||||
const code = [
|
const code = [
|
||||||
`// Resize browser window to ${validatedParams.width}x${validatedParams.height}`,
|
`// Resize browser window to ${params.width}x${params.height}`,
|
||||||
`await page.setViewportSize({ width: ${validatedParams.width}, height: ${validatedParams.height} });`
|
`await page.setViewportSize({ width: ${params.width}, height: ${params.height} });`
|
||||||
];
|
];
|
||||||
|
|
||||||
const action = async () => {
|
const action = async () => {
|
||||||
await tab.page.setViewportSize({ width: validatedParams.width, height: validatedParams.height });
|
await tab.page.setViewportSize({ width: params.width, height: params.height });
|
||||||
};
|
};
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -15,21 +15,17 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
import { zodToJsonSchema } from 'zod-to-json-schema';
|
import { defineTool } from './tool.js';
|
||||||
|
|
||||||
import type { Tool } from './tool';
|
const console = defineTool({
|
||||||
|
|
||||||
const consoleSchema = z.object({});
|
|
||||||
|
|
||||||
const console: Tool = {
|
|
||||||
capability: 'core',
|
capability: 'core',
|
||||||
schema: {
|
schema: {
|
||||||
name: 'browser_console_messages',
|
name: 'browser_console_messages',
|
||||||
description: 'Returns all console messages',
|
description: 'Returns all console messages',
|
||||||
inputSchema: zodToJsonSchema(consoleSchema),
|
inputSchema: z.object({}),
|
||||||
},
|
},
|
||||||
handle: async context => {
|
handle: async context => {
|
||||||
const messages = await context.currentTabOrDie().console();
|
const messages = context.currentTabOrDie().console();
|
||||||
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>`],
|
||||||
@@ -42,7 +38,7 @@ const console: Tool = {
|
|||||||
waitForNetwork: false,
|
waitForNetwork: false,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
};
|
});
|
||||||
|
|
||||||
export default [
|
export default [
|
||||||
console,
|
console,
|
||||||
|
|||||||
@@ -15,32 +15,27 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
import { zodToJsonSchema } from 'zod-to-json-schema';
|
import { defineTool, type ToolFactory } from './tool.js';
|
||||||
|
|
||||||
import type { ToolFactory } from './tool';
|
const handleDialog: ToolFactory = captureSnapshot => defineTool({
|
||||||
|
|
||||||
const handleDialogSchema = z.object({
|
|
||||||
accept: z.boolean().describe('Whether to accept the dialog.'),
|
|
||||||
promptText: z.string().optional().describe('The text of the prompt in case of a prompt dialog.'),
|
|
||||||
});
|
|
||||||
|
|
||||||
const handleDialog: ToolFactory = captureSnapshot => ({
|
|
||||||
capability: 'core',
|
capability: 'core',
|
||||||
|
|
||||||
schema: {
|
schema: {
|
||||||
name: 'browser_handle_dialog',
|
name: 'browser_handle_dialog',
|
||||||
description: 'Handle a dialog',
|
description: 'Handle a dialog',
|
||||||
inputSchema: zodToJsonSchema(handleDialogSchema),
|
inputSchema: z.object({
|
||||||
|
accept: z.boolean().describe('Whether to accept the dialog.'),
|
||||||
|
promptText: z.string().optional().describe('The text of the prompt in case of a prompt dialog.'),
|
||||||
|
}),
|
||||||
},
|
},
|
||||||
|
|
||||||
handle: async (context, params) => {
|
handle: async (context, params) => {
|
||||||
const validatedParams = handleDialogSchema.parse(params);
|
|
||||||
const dialogState = context.modalStates().find(state => state.type === 'dialog');
|
const dialogState = context.modalStates().find(state => state.type === 'dialog');
|
||||||
if (!dialogState)
|
if (!dialogState)
|
||||||
throw new Error('No dialog visible');
|
throw new Error('No dialog visible');
|
||||||
|
|
||||||
if (validatedParams.accept)
|
if (params.accept)
|
||||||
await dialogState.dialog.accept(validatedParams.promptText);
|
await dialogState.dialog.accept(params.promptText);
|
||||||
else
|
else
|
||||||
await dialogState.dialog.dismiss();
|
await dialogState.dialog.dismiss();
|
||||||
|
|
||||||
|
|||||||
@@ -15,35 +15,30 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
import { zodToJsonSchema } from 'zod-to-json-schema';
|
import { defineTool, type ToolFactory } from './tool.js';
|
||||||
|
|
||||||
import type { ToolFactory } from './tool';
|
const uploadFile: ToolFactory = captureSnapshot => defineTool({
|
||||||
|
|
||||||
const uploadFileSchema = z.object({
|
|
||||||
paths: z.array(z.string()).describe('The absolute paths to the files to upload. Can be a single file or multiple files.'),
|
|
||||||
});
|
|
||||||
|
|
||||||
const uploadFile: ToolFactory = captureSnapshot => ({
|
|
||||||
capability: 'files',
|
capability: 'files',
|
||||||
|
|
||||||
schema: {
|
schema: {
|
||||||
name: 'browser_file_upload',
|
name: 'browser_file_upload',
|
||||||
description: 'Upload one or multiple files',
|
description: 'Upload one or multiple files',
|
||||||
inputSchema: zodToJsonSchema(uploadFileSchema),
|
inputSchema: z.object({
|
||||||
|
paths: z.array(z.string()).describe('The absolute paths to the files to upload. Can be a single file or multiple files.'),
|
||||||
|
}),
|
||||||
},
|
},
|
||||||
|
|
||||||
handle: async (context, params) => {
|
handle: async (context, params) => {
|
||||||
const validatedParams = uploadFileSchema.parse(params);
|
|
||||||
const modalState = context.modalStates().find(state => state.type === 'fileChooser');
|
const modalState = context.modalStates().find(state => state.type === 'fileChooser');
|
||||||
if (!modalState)
|
if (!modalState)
|
||||||
throw new Error('No file chooser visible');
|
throw new Error('No file chooser visible');
|
||||||
|
|
||||||
const code = [
|
const code = [
|
||||||
`// <internal code to chose files ${validatedParams.paths.join(', ')}`,
|
`// <internal code to chose files ${params.paths.join(', ')}`,
|
||||||
];
|
];
|
||||||
|
|
||||||
const action = async () => {
|
const action = async () => {
|
||||||
await modalState.fileChooser.setFiles(validatedParams.paths);
|
await modalState.fileChooser.setFiles(params.paths);
|
||||||
context.clearModalState(modalState);
|
context.clearModalState(modalState);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -18,20 +18,18 @@ import { fork } from 'child_process';
|
|||||||
import path from 'path';
|
import path from 'path';
|
||||||
|
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
import { zodToJsonSchema } from 'zod-to-json-schema';
|
import { defineTool } from './tool.js';
|
||||||
|
|
||||||
import type { Tool } from './tool';
|
const install = defineTool({
|
||||||
|
|
||||||
const install: Tool = {
|
|
||||||
capability: 'install',
|
capability: 'install',
|
||||||
schema: {
|
schema: {
|
||||||
name: 'browser_install',
|
name: 'browser_install',
|
||||||
description: 'Install the browser specified in the config. Call this if you get an error about the browser not being installed.',
|
description: 'Install the browser specified in the config. Call this if you get an error about the browser not being installed.',
|
||||||
inputSchema: zodToJsonSchema(z.object({})),
|
inputSchema: z.object({}),
|
||||||
},
|
},
|
||||||
|
|
||||||
handle: async context => {
|
handle: async context => {
|
||||||
const channel = context.options.launchOptions?.channel ?? context.options.browserName ?? 'chrome';
|
const channel = context.config.browser?.launchOptions?.channel ?? context.config.browser?.launchOptions.browserName ?? 'chrome';
|
||||||
const cli = path.join(require.resolve('playwright/package.json'), '..', 'cli.js');
|
const cli = path.join(require.resolve('playwright/package.json'), '..', 'cli.js');
|
||||||
const child = fork(cli, ['install', channel], {
|
const child = fork(cli, ['install', channel], {
|
||||||
stdio: 'pipe',
|
stdio: 'pipe',
|
||||||
@@ -53,7 +51,7 @@ const install: Tool = {
|
|||||||
waitForNetwork: false,
|
waitForNetwork: false,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
};
|
});
|
||||||
|
|
||||||
export default [
|
export default [
|
||||||
install,
|
install,
|
||||||
|
|||||||
@@ -15,33 +15,28 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
import zodToJsonSchema from 'zod-to-json-schema';
|
import { defineTool, type ToolFactory } from './tool.js';
|
||||||
|
|
||||||
import type { ToolFactory } from './tool';
|
const pressKey: ToolFactory = captureSnapshot => defineTool({
|
||||||
|
|
||||||
const pressKeySchema = z.object({
|
|
||||||
key: z.string().describe('Name of the key to press or a character to generate, such as `ArrowLeft` or `a`'),
|
|
||||||
});
|
|
||||||
|
|
||||||
const pressKey: ToolFactory = captureSnapshot => ({
|
|
||||||
capability: 'core',
|
capability: 'core',
|
||||||
|
|
||||||
schema: {
|
schema: {
|
||||||
name: 'browser_press_key',
|
name: 'browser_press_key',
|
||||||
description: 'Press a key on the keyboard',
|
description: 'Press a key on the keyboard',
|
||||||
inputSchema: zodToJsonSchema(pressKeySchema),
|
inputSchema: z.object({
|
||||||
|
key: z.string().describe('Name of the key to press or a character to generate, such as `ArrowLeft` or `a`'),
|
||||||
|
}),
|
||||||
},
|
},
|
||||||
|
|
||||||
handle: async (context, params) => {
|
handle: async (context, params) => {
|
||||||
const validatedParams = pressKeySchema.parse(params);
|
|
||||||
const tab = context.currentTabOrDie();
|
const tab = context.currentTabOrDie();
|
||||||
|
|
||||||
const code = [
|
const code = [
|
||||||
`// Press ${validatedParams.key}`,
|
`// Press ${params.key}`,
|
||||||
`await page.keyboard.press('${validatedParams.key}');`,
|
`await page.keyboard.press('${params.key}');`,
|
||||||
];
|
];
|
||||||
|
|
||||||
const action = () => tab.page.keyboard.press(validatedParams.key);
|
const action = () => tab.page.keyboard.press(params.key);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
code,
|
code,
|
||||||
|
|||||||
@@ -15,31 +15,26 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
import { zodToJsonSchema } from 'zod-to-json-schema';
|
import { defineTool, type ToolFactory } from './tool.js';
|
||||||
|
|
||||||
import type { ToolFactory } from './tool';
|
const navigate: ToolFactory = captureSnapshot => defineTool({
|
||||||
|
|
||||||
const navigateSchema = z.object({
|
|
||||||
url: z.string().describe('The URL to navigate to'),
|
|
||||||
});
|
|
||||||
|
|
||||||
const navigate: ToolFactory = captureSnapshot => ({
|
|
||||||
capability: 'core',
|
capability: 'core',
|
||||||
|
|
||||||
schema: {
|
schema: {
|
||||||
name: 'browser_navigate',
|
name: 'browser_navigate',
|
||||||
description: 'Navigate to a URL',
|
description: 'Navigate to a URL',
|
||||||
inputSchema: zodToJsonSchema(navigateSchema),
|
inputSchema: z.object({
|
||||||
|
url: z.string().describe('The URL to navigate to'),
|
||||||
|
}),
|
||||||
},
|
},
|
||||||
|
|
||||||
handle: async (context, params) => {
|
handle: async (context, params) => {
|
||||||
const validatedParams = navigateSchema.parse(params);
|
|
||||||
const tab = await context.ensureTab();
|
const tab = await context.ensureTab();
|
||||||
await tab.navigate(validatedParams.url);
|
await tab.navigate(params.url);
|
||||||
|
|
||||||
const code = [
|
const code = [
|
||||||
`// Navigate to ${validatedParams.url}`,
|
`// Navigate to ${params.url}`,
|
||||||
`await page.goto('${validatedParams.url}');`,
|
`await page.goto('${params.url}');`,
|
||||||
];
|
];
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -50,14 +45,12 @@ const navigate: ToolFactory = captureSnapshot => ({
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const goBackSchema = z.object({});
|
const goBack: ToolFactory = captureSnapshot => defineTool({
|
||||||
|
|
||||||
const goBack: ToolFactory = captureSnapshot => ({
|
|
||||||
capability: 'history',
|
capability: 'history',
|
||||||
schema: {
|
schema: {
|
||||||
name: 'browser_navigate_back',
|
name: 'browser_navigate_back',
|
||||||
description: 'Go back to the previous page',
|
description: 'Go back to the previous page',
|
||||||
inputSchema: zodToJsonSchema(goBackSchema),
|
inputSchema: z.object({}),
|
||||||
},
|
},
|
||||||
|
|
||||||
handle: async context => {
|
handle: async context => {
|
||||||
@@ -76,14 +69,12 @@ const goBack: ToolFactory = captureSnapshot => ({
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const goForwardSchema = z.object({});
|
const goForward: ToolFactory = captureSnapshot => defineTool({
|
||||||
|
|
||||||
const goForward: ToolFactory = captureSnapshot => ({
|
|
||||||
capability: 'history',
|
capability: 'history',
|
||||||
schema: {
|
schema: {
|
||||||
name: 'browser_navigate_forward',
|
name: 'browser_navigate_forward',
|
||||||
description: 'Go forward to the next page',
|
description: 'Go forward to the next page',
|
||||||
inputSchema: zodToJsonSchema(goForwardSchema),
|
inputSchema: z.object({}),
|
||||||
},
|
},
|
||||||
handle: async context => {
|
handle: async context => {
|
||||||
const tab = context.currentTabOrDie();
|
const tab = context.currentTabOrDie();
|
||||||
|
|||||||
57
src/tools/network.ts
Normal file
57
src/tools/network.ts
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
/**
|
||||||
|
* Copyright (c) Microsoft Corporation.
|
||||||
|
*
|
||||||
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
* you may not use this file except in compliance with the License.
|
||||||
|
* You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing, software
|
||||||
|
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
* See the License for the specific language governing permissions and
|
||||||
|
* limitations under the License.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { z } from 'zod';
|
||||||
|
import { defineTool } from './tool.js';
|
||||||
|
|
||||||
|
import type * as playwright from 'playwright';
|
||||||
|
|
||||||
|
const requests = defineTool({
|
||||||
|
capability: 'core',
|
||||||
|
|
||||||
|
schema: {
|
||||||
|
name: 'browser_network_requests',
|
||||||
|
description: 'Returns all network requests since loading the page',
|
||||||
|
inputSchema: z.object({}),
|
||||||
|
},
|
||||||
|
|
||||||
|
handle: async context => {
|
||||||
|
const requests = context.currentTabOrDie().requests();
|
||||||
|
const log = [...requests.entries()].map(([request, response]) => renderRequest(request, response)).join('\n');
|
||||||
|
return {
|
||||||
|
code: [`// <internal code to list network requests>`],
|
||||||
|
action: async () => {
|
||||||
|
return {
|
||||||
|
content: [{ type: 'text', text: log }]
|
||||||
|
};
|
||||||
|
},
|
||||||
|
captureSnapshot: false,
|
||||||
|
waitForNetwork: false,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
function renderRequest(request: playwright.Request, response: playwright.Response | null) {
|
||||||
|
const result: string[] = [];
|
||||||
|
result.push(`[${request.method().toUpperCase()}] ${request.url()}`);
|
||||||
|
if (response)
|
||||||
|
result.push(`=> [${response.status()}] ${response.statusText()}`);
|
||||||
|
return result.join(' ');
|
||||||
|
}
|
||||||
|
|
||||||
|
export default [
|
||||||
|
requests,
|
||||||
|
];
|
||||||
@@ -14,31 +14,24 @@
|
|||||||
* limitations under the License.
|
* limitations under the License.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import os from 'os';
|
|
||||||
import path from 'path';
|
|
||||||
|
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
import { zodToJsonSchema } from 'zod-to-json-schema';
|
import { defineTool } from './tool.js';
|
||||||
|
|
||||||
import { sanitizeForFilePath } from './utils';
|
import * as javascript from '../javascript.js';
|
||||||
import * as javascript from '../javascript';
|
import { outputFile } from '../config.js';
|
||||||
|
|
||||||
import type { Tool } from './tool';
|
const pdf = defineTool({
|
||||||
|
|
||||||
const pdfSchema = z.object({});
|
|
||||||
|
|
||||||
const pdf: Tool = {
|
|
||||||
capability: 'pdf',
|
capability: 'pdf',
|
||||||
|
|
||||||
schema: {
|
schema: {
|
||||||
name: 'browser_pdf_save',
|
name: 'browser_pdf_save',
|
||||||
description: 'Save page as PDF',
|
description: 'Save page as PDF',
|
||||||
inputSchema: zodToJsonSchema(pdfSchema),
|
inputSchema: z.object({}),
|
||||||
},
|
},
|
||||||
|
|
||||||
handle: async context => {
|
handle: async context => {
|
||||||
const tab = context.currentTabOrDie();
|
const tab = context.currentTabOrDie();
|
||||||
const fileName = path.join(os.tmpdir(), sanitizeForFilePath(`page-${new Date().toISOString()}`)) + '.pdf';
|
const fileName = await outputFile(context.config, `page-${new Date().toISOString()}.pdf`);
|
||||||
|
|
||||||
const code = [
|
const code = [
|
||||||
`// Save page as ${fileName}`,
|
`// Save page as ${fileName}`,
|
||||||
@@ -52,7 +45,7 @@ const pdf: Tool = {
|
|||||||
waitForNetwork: false,
|
waitForNetwork: false,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
};
|
});
|
||||||
|
|
||||||
export default [
|
export default [
|
||||||
pdf,
|
pdf,
|
||||||
|
|||||||
@@ -15,18 +15,20 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
import { zodToJsonSchema } from 'zod-to-json-schema';
|
import { defineTool } from './tool.js';
|
||||||
|
|
||||||
import * as javascript from '../javascript';
|
import * as javascript from '../javascript.js';
|
||||||
|
|
||||||
import type { Tool } from './tool';
|
const elementSchema = z.object({
|
||||||
|
element: z.string().describe('Human-readable element description used to obtain permission to interact with the element'),
|
||||||
|
});
|
||||||
|
|
||||||
const screenshot: Tool = {
|
const screenshot = defineTool({
|
||||||
capability: 'core',
|
capability: 'core',
|
||||||
schema: {
|
schema: {
|
||||||
name: 'browser_screen_capture',
|
name: 'browser_screen_capture',
|
||||||
description: 'Take a screenshot of the current page',
|
description: 'Take a screenshot of the current page',
|
||||||
inputSchema: zodToJsonSchema(z.object({})),
|
inputSchema: z.object({}),
|
||||||
},
|
},
|
||||||
|
|
||||||
handle: async context => {
|
handle: async context => {
|
||||||
@@ -51,33 +53,26 @@ const screenshot: Tool = {
|
|||||||
waitForNetwork: false
|
waitForNetwork: false
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
};
|
|
||||||
|
|
||||||
const elementSchema = z.object({
|
|
||||||
element: z.string().describe('Human-readable element description used to obtain permission to interact with the element'),
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const moveMouseSchema = elementSchema.extend({
|
const moveMouse = defineTool({
|
||||||
x: z.number().describe('X coordinate'),
|
|
||||||
y: z.number().describe('Y coordinate'),
|
|
||||||
});
|
|
||||||
|
|
||||||
const moveMouse: Tool = {
|
|
||||||
capability: 'core',
|
capability: 'core',
|
||||||
schema: {
|
schema: {
|
||||||
name: 'browser_screen_move_mouse',
|
name: 'browser_screen_move_mouse',
|
||||||
description: 'Move mouse to a given position',
|
description: 'Move mouse to a given position',
|
||||||
inputSchema: zodToJsonSchema(moveMouseSchema),
|
inputSchema: elementSchema.extend({
|
||||||
|
x: z.number().describe('X coordinate'),
|
||||||
|
y: z.number().describe('Y coordinate'),
|
||||||
|
}),
|
||||||
},
|
},
|
||||||
|
|
||||||
handle: async (context, params) => {
|
handle: async (context, params) => {
|
||||||
const validatedParams = moveMouseSchema.parse(params);
|
|
||||||
const tab = context.currentTabOrDie();
|
const tab = context.currentTabOrDie();
|
||||||
const code = [
|
const code = [
|
||||||
`// Move mouse to (${validatedParams.x}, ${validatedParams.y})`,
|
`// Move mouse to (${params.x}, ${params.y})`,
|
||||||
`await page.mouse.move(${validatedParams.x}, ${validatedParams.y});`,
|
`await page.mouse.move(${params.x}, ${params.y});`,
|
||||||
];
|
];
|
||||||
const action = () => tab.page.mouse.move(validatedParams.x, validatedParams.y);
|
const action = () => tab.page.mouse.move(params.x, params.y);
|
||||||
return {
|
return {
|
||||||
code,
|
code,
|
||||||
action,
|
action,
|
||||||
@@ -85,32 +80,29 @@ const moveMouse: Tool = {
|
|||||||
waitForNetwork: false
|
waitForNetwork: false
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
};
|
|
||||||
|
|
||||||
const clickSchema = elementSchema.extend({
|
|
||||||
x: z.number().describe('X coordinate'),
|
|
||||||
y: z.number().describe('Y coordinate'),
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const click: Tool = {
|
const click = defineTool({
|
||||||
capability: 'core',
|
capability: 'core',
|
||||||
schema: {
|
schema: {
|
||||||
name: 'browser_screen_click',
|
name: 'browser_screen_click',
|
||||||
description: 'Click left mouse button',
|
description: 'Click left mouse button',
|
||||||
inputSchema: zodToJsonSchema(clickSchema),
|
inputSchema: elementSchema.extend({
|
||||||
|
x: z.number().describe('X coordinate'),
|
||||||
|
y: z.number().describe('Y coordinate'),
|
||||||
|
}),
|
||||||
},
|
},
|
||||||
|
|
||||||
handle: async (context, params) => {
|
handle: async (context, params) => {
|
||||||
const validatedParams = clickSchema.parse(params);
|
|
||||||
const tab = context.currentTabOrDie();
|
const tab = context.currentTabOrDie();
|
||||||
const code = [
|
const code = [
|
||||||
`// Click mouse at coordinates (${validatedParams.x}, ${validatedParams.y})`,
|
`// Click mouse at coordinates (${params.x}, ${params.y})`,
|
||||||
`await page.mouse.move(${validatedParams.x}, ${validatedParams.y});`,
|
`await page.mouse.move(${params.x}, ${params.y});`,
|
||||||
`await page.mouse.down();`,
|
`await page.mouse.down();`,
|
||||||
`await page.mouse.up();`,
|
`await page.mouse.up();`,
|
||||||
];
|
];
|
||||||
const action = async () => {
|
const action = async () => {
|
||||||
await tab.page.mouse.move(validatedParams.x, validatedParams.y);
|
await tab.page.mouse.move(params.x, params.y);
|
||||||
await tab.page.mouse.down();
|
await tab.page.mouse.down();
|
||||||
await tab.page.mouse.up();
|
await tab.page.mouse.up();
|
||||||
};
|
};
|
||||||
@@ -121,40 +113,37 @@ const click: Tool = {
|
|||||||
waitForNetwork: true,
|
waitForNetwork: true,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
};
|
|
||||||
|
|
||||||
const dragSchema = elementSchema.extend({
|
|
||||||
startX: z.number().describe('Start X coordinate'),
|
|
||||||
startY: z.number().describe('Start Y coordinate'),
|
|
||||||
endX: z.number().describe('End X coordinate'),
|
|
||||||
endY: z.number().describe('End Y coordinate'),
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const drag: Tool = {
|
const drag = defineTool({
|
||||||
capability: 'core',
|
capability: 'core',
|
||||||
|
|
||||||
schema: {
|
schema: {
|
||||||
name: 'browser_screen_drag',
|
name: 'browser_screen_drag',
|
||||||
description: 'Drag left mouse button',
|
description: 'Drag left mouse button',
|
||||||
inputSchema: zodToJsonSchema(dragSchema),
|
inputSchema: elementSchema.extend({
|
||||||
|
startX: z.number().describe('Start X coordinate'),
|
||||||
|
startY: z.number().describe('Start Y coordinate'),
|
||||||
|
endX: z.number().describe('End X coordinate'),
|
||||||
|
endY: z.number().describe('End Y coordinate'),
|
||||||
|
}),
|
||||||
},
|
},
|
||||||
|
|
||||||
handle: async (context, params) => {
|
handle: async (context, params) => {
|
||||||
const validatedParams = dragSchema.parse(params);
|
|
||||||
const tab = context.currentTabOrDie();
|
const tab = context.currentTabOrDie();
|
||||||
|
|
||||||
const code = [
|
const code = [
|
||||||
`// Drag mouse from (${validatedParams.startX}, ${validatedParams.startY}) to (${validatedParams.endX}, ${validatedParams.endY})`,
|
`// Drag mouse from (${params.startX}, ${params.startY}) to (${params.endX}, ${params.endY})`,
|
||||||
`await page.mouse.move(${validatedParams.startX}, ${validatedParams.startY});`,
|
`await page.mouse.move(${params.startX}, ${params.startY});`,
|
||||||
`await page.mouse.down();`,
|
`await page.mouse.down();`,
|
||||||
`await page.mouse.move(${validatedParams.endX}, ${validatedParams.endY});`,
|
`await page.mouse.move(${params.endX}, ${params.endY});`,
|
||||||
`await page.mouse.up();`,
|
`await page.mouse.up();`,
|
||||||
];
|
];
|
||||||
|
|
||||||
const action = async () => {
|
const action = async () => {
|
||||||
await tab.page.mouse.move(validatedParams.startX, validatedParams.startY);
|
await tab.page.mouse.move(params.startX, params.startY);
|
||||||
await tab.page.mouse.down();
|
await tab.page.mouse.down();
|
||||||
await tab.page.mouse.move(validatedParams.endX, validatedParams.endY);
|
await tab.page.mouse.move(params.endX, params.endY);
|
||||||
await tab.page.mouse.up();
|
await tab.page.mouse.up();
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -165,38 +154,35 @@ const drag: Tool = {
|
|||||||
waitForNetwork: true,
|
waitForNetwork: true,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
};
|
|
||||||
|
|
||||||
const typeSchema = z.object({
|
|
||||||
text: z.string().describe('Text to type into the element'),
|
|
||||||
submit: z.boolean().optional().describe('Whether to submit entered text (press Enter after)'),
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const type: Tool = {
|
const type = defineTool({
|
||||||
capability: 'core',
|
capability: 'core',
|
||||||
|
|
||||||
schema: {
|
schema: {
|
||||||
name: 'browser_screen_type',
|
name: 'browser_screen_type',
|
||||||
description: 'Type text',
|
description: 'Type text',
|
||||||
inputSchema: zodToJsonSchema(typeSchema),
|
inputSchema: z.object({
|
||||||
|
text: z.string().describe('Text to type into the element'),
|
||||||
|
submit: z.boolean().optional().describe('Whether to submit entered text (press Enter after)'),
|
||||||
|
}),
|
||||||
},
|
},
|
||||||
|
|
||||||
handle: async (context, params) => {
|
handle: async (context, params) => {
|
||||||
const validatedParams = typeSchema.parse(params);
|
|
||||||
const tab = context.currentTabOrDie();
|
const tab = context.currentTabOrDie();
|
||||||
|
|
||||||
const code = [
|
const code = [
|
||||||
`// Type ${validatedParams.text}`,
|
`// Type ${params.text}`,
|
||||||
`await page.keyboard.type('${validatedParams.text}');`,
|
`await page.keyboard.type('${params.text}');`,
|
||||||
];
|
];
|
||||||
|
|
||||||
const action = async () => {
|
const action = async () => {
|
||||||
await tab.page.keyboard.type(validatedParams.text);
|
await tab.page.keyboard.type(params.text);
|
||||||
if (validatedParams.submit)
|
if (params.submit)
|
||||||
await tab.page.keyboard.press('Enter');
|
await tab.page.keyboard.press('Enter');
|
||||||
};
|
};
|
||||||
|
|
||||||
if (validatedParams.submit) {
|
if (params.submit) {
|
||||||
code.push(`// Submit text`);
|
code.push(`// Submit text`);
|
||||||
code.push(`await page.keyboard.press('Enter');`);
|
code.push(`await page.keyboard.press('Enter');`);
|
||||||
}
|
}
|
||||||
@@ -208,7 +194,7 @@ const type: Tool = {
|
|||||||
waitForNetwork: true,
|
waitForNetwork: true,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
};
|
});
|
||||||
|
|
||||||
export default [
|
export default [
|
||||||
screenshot,
|
screenshot,
|
||||||
|
|||||||
@@ -14,25 +14,20 @@
|
|||||||
* limitations under the License.
|
* limitations under the License.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import path from 'path';
|
|
||||||
import os from 'os';
|
|
||||||
|
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
import zodToJsonSchema from 'zod-to-json-schema';
|
|
||||||
|
|
||||||
import { sanitizeForFilePath } from './utils';
|
import { defineTool } from './tool.js';
|
||||||
import { generateLocator } from '../context';
|
import * as javascript from '../javascript.js';
|
||||||
import * as javascript from '../javascript';
|
import { outputFile } from '../config.js';
|
||||||
|
|
||||||
import type * as playwright from 'playwright';
|
import type * as playwright from 'playwright';
|
||||||
import type { Tool } from './tool';
|
|
||||||
|
|
||||||
const snapshot: Tool = {
|
const snapshot = defineTool({
|
||||||
capability: 'core',
|
capability: 'core',
|
||||||
schema: {
|
schema: {
|
||||||
name: 'browser_snapshot',
|
name: 'browser_snapshot',
|
||||||
description: 'Capture accessibility snapshot of the current page, this is better than screenshot',
|
description: 'Capture accessibility snapshot of the current page, this is better than screenshot',
|
||||||
inputSchema: zodToJsonSchema(z.object({})),
|
inputSchema: z.object({}),
|
||||||
},
|
},
|
||||||
|
|
||||||
handle: async context => {
|
handle: async context => {
|
||||||
@@ -44,28 +39,27 @@ const snapshot: Tool = {
|
|||||||
waitForNetwork: false,
|
waitForNetwork: false,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
};
|
});
|
||||||
|
|
||||||
const elementSchema = z.object({
|
const elementSchema = z.object({
|
||||||
element: z.string().describe('Human-readable element description used to obtain permission to interact with the element'),
|
element: z.string().describe('Human-readable element description used to obtain permission to interact with the element'),
|
||||||
ref: z.string().describe('Exact target element reference from the page snapshot'),
|
ref: z.string().describe('Exact target element reference from the page snapshot'),
|
||||||
});
|
});
|
||||||
|
|
||||||
const click: Tool = {
|
const click = defineTool({
|
||||||
capability: 'core',
|
capability: 'core',
|
||||||
schema: {
|
schema: {
|
||||||
name: 'browser_click',
|
name: 'browser_click',
|
||||||
description: 'Perform click on a web page',
|
description: 'Perform click on a web page',
|
||||||
inputSchema: zodToJsonSchema(elementSchema),
|
inputSchema: elementSchema,
|
||||||
},
|
},
|
||||||
|
|
||||||
handle: async (context, params) => {
|
handle: async (context, params) => {
|
||||||
const validatedParams = elementSchema.parse(params);
|
|
||||||
const tab = context.currentTabOrDie();
|
const tab = context.currentTabOrDie();
|
||||||
const locator = tab.snapshotOrDie().refLocator(validatedParams.ref);
|
const locator = tab.snapshotOrDie().refLocator(params.ref);
|
||||||
|
|
||||||
const code = [
|
const code = [
|
||||||
`// Click ${validatedParams.element}`,
|
`// Click ${params.element}`,
|
||||||
`await page.${await generateLocator(locator)}.click();`
|
`await page.${await generateLocator(locator)}.click();`
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -76,31 +70,28 @@ const click: Tool = {
|
|||||||
waitForNetwork: true,
|
waitForNetwork: true,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
};
|
|
||||||
|
|
||||||
const dragSchema = z.object({
|
|
||||||
startElement: z.string().describe('Human-readable source element description used to obtain the permission to interact with the element'),
|
|
||||||
startRef: z.string().describe('Exact source element reference from the page snapshot'),
|
|
||||||
endElement: z.string().describe('Human-readable target element description used to obtain the permission to interact with the element'),
|
|
||||||
endRef: z.string().describe('Exact target element reference from the page snapshot'),
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const drag: Tool = {
|
const drag = defineTool({
|
||||||
capability: 'core',
|
capability: 'core',
|
||||||
schema: {
|
schema: {
|
||||||
name: 'browser_drag',
|
name: 'browser_drag',
|
||||||
description: 'Perform drag and drop between two elements',
|
description: 'Perform drag and drop between two elements',
|
||||||
inputSchema: zodToJsonSchema(dragSchema),
|
inputSchema: z.object({
|
||||||
|
startElement: z.string().describe('Human-readable source element description used to obtain the permission to interact with the element'),
|
||||||
|
startRef: z.string().describe('Exact source element reference from the page snapshot'),
|
||||||
|
endElement: z.string().describe('Human-readable target element description used to obtain the permission to interact with the element'),
|
||||||
|
endRef: z.string().describe('Exact target element reference from the page snapshot'),
|
||||||
|
}),
|
||||||
},
|
},
|
||||||
|
|
||||||
handle: async (context, params) => {
|
handle: async (context, params) => {
|
||||||
const validatedParams = dragSchema.parse(params);
|
|
||||||
const snapshot = context.currentTabOrDie().snapshotOrDie();
|
const snapshot = context.currentTabOrDie().snapshotOrDie();
|
||||||
const startLocator = snapshot.refLocator(validatedParams.startRef);
|
const startLocator = snapshot.refLocator(params.startRef);
|
||||||
const endLocator = snapshot.refLocator(validatedParams.endRef);
|
const endLocator = snapshot.refLocator(params.endRef);
|
||||||
|
|
||||||
const code = [
|
const code = [
|
||||||
`// Drag ${validatedParams.startElement} to ${validatedParams.endElement}`,
|
`// Drag ${params.startElement} to ${params.endElement}`,
|
||||||
`await page.${await generateLocator(startLocator)}.dragTo(page.${await generateLocator(endLocator)});`
|
`await page.${await generateLocator(startLocator)}.dragTo(page.${await generateLocator(endLocator)});`
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -111,23 +102,22 @@ const drag: Tool = {
|
|||||||
waitForNetwork: true,
|
waitForNetwork: true,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
};
|
});
|
||||||
|
|
||||||
const hover: Tool = {
|
const hover = defineTool({
|
||||||
capability: 'core',
|
capability: 'core',
|
||||||
schema: {
|
schema: {
|
||||||
name: 'browser_hover',
|
name: 'browser_hover',
|
||||||
description: 'Hover over element on page',
|
description: 'Hover over element on page',
|
||||||
inputSchema: zodToJsonSchema(elementSchema),
|
inputSchema: elementSchema,
|
||||||
},
|
},
|
||||||
|
|
||||||
handle: async (context, params) => {
|
handle: async (context, params) => {
|
||||||
const validatedParams = elementSchema.parse(params);
|
|
||||||
const snapshot = context.currentTabOrDie().snapshotOrDie();
|
const snapshot = context.currentTabOrDie().snapshotOrDie();
|
||||||
const locator = snapshot.refLocator(validatedParams.ref);
|
const locator = snapshot.refLocator(params.ref);
|
||||||
|
|
||||||
const code = [
|
const code = [
|
||||||
`// Hover over ${validatedParams.element}`,
|
`// Hover over ${params.element}`,
|
||||||
`await page.${await generateLocator(locator)}.hover();`
|
`await page.${await generateLocator(locator)}.hover();`
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -138,7 +128,7 @@ const hover: Tool = {
|
|||||||
waitForNetwork: true,
|
waitForNetwork: true,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
};
|
});
|
||||||
|
|
||||||
const typeSchema = elementSchema.extend({
|
const typeSchema = elementSchema.extend({
|
||||||
text: z.string().describe('Text to type into the element'),
|
text: z.string().describe('Text to type into the element'),
|
||||||
@@ -146,33 +136,32 @@ const typeSchema = elementSchema.extend({
|
|||||||
slowly: z.boolean().optional().describe('Whether to type one character at a time. Useful for triggering key handlers in the page. By default entire text is filled in at once.'),
|
slowly: z.boolean().optional().describe('Whether to type one character at a time. Useful for triggering key handlers in the page. By default entire text is filled in at once.'),
|
||||||
});
|
});
|
||||||
|
|
||||||
const type: Tool = {
|
const type = defineTool({
|
||||||
capability: 'core',
|
capability: 'core',
|
||||||
schema: {
|
schema: {
|
||||||
name: 'browser_type',
|
name: 'browser_type',
|
||||||
description: 'Type text into editable element',
|
description: 'Type text into editable element',
|
||||||
inputSchema: zodToJsonSchema(typeSchema),
|
inputSchema: typeSchema,
|
||||||
},
|
},
|
||||||
|
|
||||||
handle: async (context, params) => {
|
handle: async (context, params) => {
|
||||||
const validatedParams = typeSchema.parse(params);
|
|
||||||
const snapshot = context.currentTabOrDie().snapshotOrDie();
|
const snapshot = context.currentTabOrDie().snapshotOrDie();
|
||||||
const locator = snapshot.refLocator(validatedParams.ref);
|
const locator = snapshot.refLocator(params.ref);
|
||||||
|
|
||||||
const code: string[] = [];
|
const code: string[] = [];
|
||||||
const steps: (() => Promise<void>)[] = [];
|
const steps: (() => Promise<void>)[] = [];
|
||||||
|
|
||||||
if (validatedParams.slowly) {
|
if (params.slowly) {
|
||||||
code.push(`// Press "${validatedParams.text}" sequentially into "${validatedParams.element}"`);
|
code.push(`// Press "${params.text}" sequentially into "${params.element}"`);
|
||||||
code.push(`await page.${await generateLocator(locator)}.pressSequentially(${javascript.quote(validatedParams.text)});`);
|
code.push(`await page.${await generateLocator(locator)}.pressSequentially(${javascript.quote(params.text)});`);
|
||||||
steps.push(() => locator.pressSequentially(validatedParams.text));
|
steps.push(() => locator.pressSequentially(params.text));
|
||||||
} else {
|
} else {
|
||||||
code.push(`// Fill "${validatedParams.text}" into "${validatedParams.element}"`);
|
code.push(`// Fill "${params.text}" into "${params.element}"`);
|
||||||
code.push(`await page.${await generateLocator(locator)}.fill(${javascript.quote(validatedParams.text)});`);
|
code.push(`await page.${await generateLocator(locator)}.fill(${javascript.quote(params.text)});`);
|
||||||
steps.push(() => locator.fill(validatedParams.text));
|
steps.push(() => locator.fill(params.text));
|
||||||
}
|
}
|
||||||
|
|
||||||
if (validatedParams.submit) {
|
if (params.submit) {
|
||||||
code.push(`// Submit text`);
|
code.push(`// Submit text`);
|
||||||
code.push(`await page.${await generateLocator(locator)}.press('Enter');`);
|
code.push(`await page.${await generateLocator(locator)}.press('Enter');`);
|
||||||
steps.push(() => locator.press('Enter'));
|
steps.push(() => locator.press('Enter'));
|
||||||
@@ -185,38 +174,37 @@ const type: Tool = {
|
|||||||
waitForNetwork: true,
|
waitForNetwork: true,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
};
|
});
|
||||||
|
|
||||||
const selectOptionSchema = elementSchema.extend({
|
const selectOptionSchema = elementSchema.extend({
|
||||||
values: z.array(z.string()).describe('Array of values to select in the dropdown. This can be a single value or multiple values.'),
|
values: z.array(z.string()).describe('Array of values to select in the dropdown. This can be a single value or multiple values.'),
|
||||||
});
|
});
|
||||||
|
|
||||||
const selectOption: Tool = {
|
const selectOption = defineTool({
|
||||||
capability: 'core',
|
capability: 'core',
|
||||||
schema: {
|
schema: {
|
||||||
name: 'browser_select_option',
|
name: 'browser_select_option',
|
||||||
description: 'Select an option in a dropdown',
|
description: 'Select an option in a dropdown',
|
||||||
inputSchema: zodToJsonSchema(selectOptionSchema),
|
inputSchema: selectOptionSchema,
|
||||||
},
|
},
|
||||||
|
|
||||||
handle: async (context, params) => {
|
handle: async (context, params) => {
|
||||||
const validatedParams = selectOptionSchema.parse(params);
|
|
||||||
const snapshot = context.currentTabOrDie().snapshotOrDie();
|
const snapshot = context.currentTabOrDie().snapshotOrDie();
|
||||||
const locator = snapshot.refLocator(validatedParams.ref);
|
const locator = snapshot.refLocator(params.ref);
|
||||||
|
|
||||||
const code = [
|
const code = [
|
||||||
`// Select options [${validatedParams.values.join(', ')}] in ${validatedParams.element}`,
|
`// Select options [${params.values.join(', ')}] in ${params.element}`,
|
||||||
`await page.${await generateLocator(locator)}.selectOption(${javascript.formatObject(validatedParams.values)});`
|
`await page.${await generateLocator(locator)}.selectOption(${javascript.formatObject(params.values)});`
|
||||||
];
|
];
|
||||||
|
|
||||||
return {
|
return {
|
||||||
code,
|
code,
|
||||||
action: () => locator.selectOption(validatedParams.values).then(() => {}),
|
action: () => locator.selectOption(params.values).then(() => {}),
|
||||||
captureSnapshot: true,
|
captureSnapshot: true,
|
||||||
waitForNetwork: true,
|
waitForNetwork: true,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
};
|
});
|
||||||
|
|
||||||
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.'),
|
||||||
@@ -229,42 +217,42 @@ const screenshotSchema = z.object({
|
|||||||
path: ['ref', 'element']
|
path: ['ref', 'element']
|
||||||
});
|
});
|
||||||
|
|
||||||
const screenshot: Tool = {
|
const screenshot = defineTool({
|
||||||
capability: 'core',
|
capability: 'core',
|
||||||
schema: {
|
schema: {
|
||||||
name: 'browser_take_screenshot',
|
name: 'browser_take_screenshot',
|
||||||
description: `Take a screenshot of the current page. You can't perform actions based on the screenshot, use browser_snapshot for actions.`,
|
description: `Take a screenshot of the current page. You can't perform actions based on the screenshot, use browser_snapshot for actions.`,
|
||||||
inputSchema: zodToJsonSchema(screenshotSchema),
|
inputSchema: screenshotSchema,
|
||||||
},
|
},
|
||||||
|
|
||||||
handle: async (context, params) => {
|
handle: async (context, params) => {
|
||||||
const validatedParams = screenshotSchema.parse(params);
|
|
||||||
const tab = context.currentTabOrDie();
|
const tab = context.currentTabOrDie();
|
||||||
const snapshot = tab.snapshotOrDie();
|
const snapshot = tab.snapshotOrDie();
|
||||||
const fileType = validatedParams.raw ? 'png' : 'jpeg';
|
const fileType = params.raw ? 'png' : 'jpeg';
|
||||||
const fileName = path.join(os.tmpdir(), sanitizeForFilePath(`page-${new Date().toISOString()}`)) + `.${fileType}`;
|
const fileName = await outputFile(context.config, `page-${new Date().toISOString()}.${fileType}`);
|
||||||
const options: playwright.PageScreenshotOptions = { type: fileType, quality: fileType === 'png' ? undefined : 50, scale: 'css', path: fileName };
|
const options: playwright.PageScreenshotOptions = { type: fileType, quality: fileType === 'png' ? undefined : 50, scale: 'css', path: fileName };
|
||||||
const isElementScreenshot = validatedParams.element && validatedParams.ref;
|
const isElementScreenshot = params.element && params.ref;
|
||||||
|
|
||||||
const code = [
|
const code = [
|
||||||
`// Screenshot ${isElementScreenshot ? validatedParams.element : 'viewport'} and save it as ${fileName}`,
|
`// Screenshot ${isElementScreenshot ? params.element : 'viewport'} and save it as ${fileName}`,
|
||||||
];
|
];
|
||||||
|
|
||||||
const locator = validatedParams.ref ? snapshot.refLocator(validatedParams.ref) : null;
|
const locator = params.ref ? snapshot.refLocator(params.ref) : null;
|
||||||
|
|
||||||
if (locator)
|
if (locator)
|
||||||
code.push(`await page.${await generateLocator(locator)}.screenshot(${javascript.formatObject(options)});`);
|
code.push(`await page.${await generateLocator(locator)}.screenshot(${javascript.formatObject(options)});`);
|
||||||
else
|
else
|
||||||
code.push(`await page.screenshot(${javascript.formatObject(options)});`);
|
code.push(`await page.screenshot(${javascript.formatObject(options)});`);
|
||||||
|
|
||||||
|
const includeBase64 = !context.config.tools?.browser_take_screenshot?.omitBase64;
|
||||||
const action = async () => {
|
const action = async () => {
|
||||||
const screenshot = locator ? await locator.screenshot(options) : await tab.page.screenshot(options);
|
const screenshot = locator ? await locator.screenshot(options) : await tab.page.screenshot(options);
|
||||||
return {
|
return {
|
||||||
content: [{
|
content: includeBase64 ? [{
|
||||||
type: 'image' as 'image',
|
type: 'image' as 'image',
|
||||||
data: screenshot.toString('base64'),
|
data: screenshot.toString('base64'),
|
||||||
mimeType: fileType === 'png' ? 'image/png' : 'image/jpeg',
|
mimeType: fileType === 'png' ? 'image/png' : 'image/jpeg',
|
||||||
}]
|
}] : []
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -275,8 +263,11 @@ const screenshot: Tool = {
|
|||||||
waitForNetwork: false,
|
waitForNetwork: false,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
};
|
});
|
||||||
|
|
||||||
|
export async function generateLocator(locator: playwright.Locator): Promise<string> {
|
||||||
|
return (locator as any)._generateLocatorString();
|
||||||
|
}
|
||||||
|
|
||||||
export default [
|
export default [
|
||||||
snapshot,
|
snapshot,
|
||||||
|
|||||||
@@ -15,17 +15,15 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
import { zodToJsonSchema } from 'zod-to-json-schema';
|
import { defineTool, type ToolFactory } from './tool.js';
|
||||||
|
|
||||||
import type { ToolFactory, Tool } from './tool';
|
const listTabs = defineTool({
|
||||||
|
|
||||||
const listTabs: Tool = {
|
|
||||||
capability: 'tabs',
|
capability: 'tabs',
|
||||||
|
|
||||||
schema: {
|
schema: {
|
||||||
name: 'browser_tab_list',
|
name: 'browser_tab_list',
|
||||||
description: 'List browser tabs',
|
description: 'List browser tabs',
|
||||||
inputSchema: zodToJsonSchema(z.object({})),
|
inputSchema: z.object({}),
|
||||||
},
|
},
|
||||||
|
|
||||||
handle: async context => {
|
handle: async context => {
|
||||||
@@ -42,26 +40,23 @@ const listTabs: Tool = {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
};
|
|
||||||
|
|
||||||
const selectTabSchema = z.object({
|
|
||||||
index: z.number().describe('The index of the tab to select'),
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const selectTab: ToolFactory = captureSnapshot => ({
|
const selectTab: ToolFactory = captureSnapshot => defineTool({
|
||||||
capability: 'tabs',
|
capability: 'tabs',
|
||||||
|
|
||||||
schema: {
|
schema: {
|
||||||
name: 'browser_tab_select',
|
name: 'browser_tab_select',
|
||||||
description: 'Select a tab by index',
|
description: 'Select a tab by index',
|
||||||
inputSchema: zodToJsonSchema(selectTabSchema),
|
inputSchema: z.object({
|
||||||
|
index: z.number().describe('The index of the tab to select'),
|
||||||
|
}),
|
||||||
},
|
},
|
||||||
|
|
||||||
handle: async (context, params) => {
|
handle: async (context, params) => {
|
||||||
const validatedParams = selectTabSchema.parse(params);
|
await context.selectTab(params.index);
|
||||||
await context.selectTab(validatedParams.index);
|
|
||||||
const code = [
|
const code = [
|
||||||
`// <internal code to select tab ${validatedParams.index}>`,
|
`// <internal code to select tab ${params.index}>`,
|
||||||
];
|
];
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -72,24 +67,21 @@ const selectTab: ToolFactory = captureSnapshot => ({
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const newTabSchema = z.object({
|
const newTab: ToolFactory = captureSnapshot => defineTool({
|
||||||
url: z.string().optional().describe('The URL to navigate to in the new tab. If not provided, the new tab will be blank.'),
|
|
||||||
});
|
|
||||||
|
|
||||||
const newTab: ToolFactory = captureSnapshot => ({
|
|
||||||
capability: 'tabs',
|
capability: 'tabs',
|
||||||
|
|
||||||
schema: {
|
schema: {
|
||||||
name: 'browser_tab_new',
|
name: 'browser_tab_new',
|
||||||
description: 'Open a new tab',
|
description: 'Open a new tab',
|
||||||
inputSchema: zodToJsonSchema(newTabSchema),
|
inputSchema: z.object({
|
||||||
|
url: z.string().optional().describe('The URL to navigate to in the new tab. If not provided, the new tab will be blank.'),
|
||||||
|
}),
|
||||||
},
|
},
|
||||||
|
|
||||||
handle: async (context, params) => {
|
handle: async (context, params) => {
|
||||||
const validatedParams = newTabSchema.parse(params);
|
|
||||||
await context.newTab();
|
await context.newTab();
|
||||||
if (validatedParams.url)
|
if (params.url)
|
||||||
await context.currentTabOrDie().navigate(validatedParams.url);
|
await context.currentTabOrDie().navigate(params.url);
|
||||||
|
|
||||||
const code = [
|
const code = [
|
||||||
`// <internal code to open a new tab>`,
|
`// <internal code to open a new tab>`,
|
||||||
@@ -102,24 +94,21 @@ const newTab: ToolFactory = captureSnapshot => ({
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const closeTabSchema = z.object({
|
const closeTab: ToolFactory = captureSnapshot => defineTool({
|
||||||
index: z.number().optional().describe('The index of the tab to close. Closes current tab if not provided.'),
|
|
||||||
});
|
|
||||||
|
|
||||||
const closeTab: ToolFactory = captureSnapshot => ({
|
|
||||||
capability: 'tabs',
|
capability: 'tabs',
|
||||||
|
|
||||||
schema: {
|
schema: {
|
||||||
name: 'browser_tab_close',
|
name: 'browser_tab_close',
|
||||||
description: 'Close a tab',
|
description: 'Close a tab',
|
||||||
inputSchema: zodToJsonSchema(closeTabSchema),
|
inputSchema: z.object({
|
||||||
|
index: z.number().optional().describe('The index of the tab to close. Closes current tab if not provided.'),
|
||||||
|
}),
|
||||||
},
|
},
|
||||||
|
|
||||||
handle: async (context, params) => {
|
handle: async (context, params) => {
|
||||||
const validatedParams = closeTabSchema.parse(params);
|
await context.closeTab(params.index);
|
||||||
await context.closeTab(validatedParams.index);
|
|
||||||
const code = [
|
const code = [
|
||||||
`// <internal code to close tab ${validatedParams.index}>`,
|
`// <internal code to close tab ${params.index}>`,
|
||||||
];
|
];
|
||||||
return {
|
return {
|
||||||
code,
|
code,
|
||||||
|
|||||||
65
src/tools/testing.ts
Normal file
65
src/tools/testing.ts
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
/**
|
||||||
|
* Copyright (c) Microsoft Corporation.
|
||||||
|
*
|
||||||
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
* you may not use this file except in compliance with the License.
|
||||||
|
* You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing, software
|
||||||
|
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
* See the License for the specific language governing permissions and
|
||||||
|
* limitations under the License.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { z } from 'zod';
|
||||||
|
import { defineTool } from './tool.js';
|
||||||
|
|
||||||
|
const generateTestSchema = z.object({
|
||||||
|
name: z.string().describe('The name of the test'),
|
||||||
|
description: z.string().describe('The description of the test'),
|
||||||
|
steps: z.array(z.string()).describe('The steps of the test'),
|
||||||
|
});
|
||||||
|
|
||||||
|
const generateTest = defineTool({
|
||||||
|
capability: 'testing',
|
||||||
|
|
||||||
|
schema: {
|
||||||
|
name: 'browser_generate_playwright_test',
|
||||||
|
description: 'Generate a Playwright test for given scenario',
|
||||||
|
inputSchema: generateTestSchema,
|
||||||
|
},
|
||||||
|
|
||||||
|
handle: async (context, params) => {
|
||||||
|
return {
|
||||||
|
resultOverride: {
|
||||||
|
content: [{
|
||||||
|
type: 'text',
|
||||||
|
text: instructions(params),
|
||||||
|
}],
|
||||||
|
},
|
||||||
|
code: [],
|
||||||
|
captureSnapshot: false,
|
||||||
|
waitForNetwork: false,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const instructions = (params: { name: string, description: string, steps: string[] }) => [
|
||||||
|
`## Instructions`,
|
||||||
|
`- You are a playwright test generator.`,
|
||||||
|
`- You are given a scenario and you need to generate a playwright test for it.`,
|
||||||
|
'- DO NOT generate test code based on the scenario alone. DO run steps one by one using the tools provided instead.',
|
||||||
|
'- Only after all steps are completed, emit a Playwright TypeScript test that uses @playwright/test based on message history',
|
||||||
|
'- Save generated test file in the tests directory',
|
||||||
|
`Test name: ${params.name}`,
|
||||||
|
`Description: ${params.description}`,
|
||||||
|
`Steps:`,
|
||||||
|
...params.steps.map((step, index) => `- ${index + 1}. ${step}`),
|
||||||
|
].join('\n');
|
||||||
|
|
||||||
|
export default [
|
||||||
|
generateTest,
|
||||||
|
];
|
||||||
@@ -14,18 +14,20 @@
|
|||||||
* limitations under the License.
|
* limitations under the License.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import type { ImageContent, TextContent } from '@modelcontextprotocol/sdk/types';
|
import type { ImageContent, TextContent } from '@modelcontextprotocol/sdk/types.js';
|
||||||
import type { JsonSchema7Type } from 'zod-to-json-schema';
|
import type { z } from 'zod';
|
||||||
import type { Context } from '../context';
|
import type { Context } from '../context.js';
|
||||||
import type * as playwright from 'playwright';
|
import type * as playwright from 'playwright';
|
||||||
export type ToolCapability = 'core' | 'tabs' | 'pdf' | 'history' | 'wait' | 'files' | 'install';
|
import type { ToolCapability } from '../../config.js';
|
||||||
|
|
||||||
export type ToolSchema = {
|
export type ToolSchema<Input extends InputType> = {
|
||||||
name: string;
|
name: string;
|
||||||
description: string;
|
description: string;
|
||||||
inputSchema: JsonSchema7Type;
|
inputSchema: Input;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type InputType = z.Schema;
|
||||||
|
|
||||||
export type FileUploadModalState = {
|
export type FileUploadModalState = {
|
||||||
type: 'fileChooser';
|
type: 'fileChooser';
|
||||||
description: string;
|
description: string;
|
||||||
@@ -50,11 +52,15 @@ export type ToolResult = {
|
|||||||
resultOverride?: ToolActionResult;
|
resultOverride?: ToolActionResult;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type Tool = {
|
export type Tool<Input extends InputType = InputType> = {
|
||||||
capability: ToolCapability;
|
capability: ToolCapability;
|
||||||
schema: ToolSchema;
|
schema: ToolSchema<Input>;
|
||||||
clearsModalState?: ModalState['type'];
|
clearsModalState?: ModalState['type'];
|
||||||
handle: (context: Context, params?: Record<string, any>) => Promise<ToolResult>;
|
handle: (context: Context, params: z.output<Input>) => Promise<ToolResult>;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type ToolFactory = (snapshot: boolean) => Tool;
|
export type ToolFactory = (snapshot: boolean) => Tool<any>;
|
||||||
|
|
||||||
|
export function defineTool<Input extends InputType>(tool: Tool<Input>): Tool<Input> {
|
||||||
|
return tool;
|
||||||
|
}
|
||||||
|
|||||||
@@ -15,7 +15,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import type * as playwright from 'playwright';
|
import type * as playwright from 'playwright';
|
||||||
import type { Context } from '../context';
|
import type { Context } from '../context.js';
|
||||||
|
|
||||||
export async function waitForCompletion<R>(context: Context, page: playwright.Page, callback: () => Promise<R>): Promise<R> {
|
export async function waitForCompletion<R>(context: Context, page: playwright.Page, callback: () => Promise<R>): Promise<R> {
|
||||||
const requests = new Set<playwright.Request>();
|
const requests = new Set<playwright.Request>();
|
||||||
@@ -71,5 +71,9 @@ export async function waitForCompletion<R>(context: Context, page: playwright.Pa
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function sanitizeForFilePath(s: string) {
|
export function sanitizeForFilePath(s: string) {
|
||||||
return s.replace(/[\x00-\x2C\x2E-\x2F\x3A-\x40\x5B-\x60\x7B-\x7F]+/g, '-');
|
const sanitize = (s: string) => s.replace(/[\x00-\x2C\x2E-\x2F\x3A-\x40\x5B-\x60\x7B-\x7F]+/g, '-');
|
||||||
|
const separator = s.lastIndexOf('.');
|
||||||
|
if (separator === -1)
|
||||||
|
return sanitize(s);
|
||||||
|
return sanitize(s.substring(0, separator)) + '.' + sanitize(s.substring(separator + 1));
|
||||||
}
|
}
|
||||||
|
|||||||
134
src/transport.ts
Normal file
134
src/transport.ts
Normal file
@@ -0,0 +1,134 @@
|
|||||||
|
/**
|
||||||
|
* 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 http from 'node:http';
|
||||||
|
import assert from 'node:assert';
|
||||||
|
import crypto from 'node:crypto';
|
||||||
|
|
||||||
|
import { ServerList } from './server.js';
|
||||||
|
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
||||||
|
import { SSEServerTransport } from '@modelcontextprotocol/sdk/server/sse.js';
|
||||||
|
import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
|
||||||
|
|
||||||
|
export async function startStdioTransport(serverList: ServerList) {
|
||||||
|
const server = await serverList.create();
|
||||||
|
await server.connect(new StdioServerTransport());
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSSE(req: http.IncomingMessage, res: http.ServerResponse, url: URL, serverList: ServerList, sessions: Map<string, SSEServerTransport>) {
|
||||||
|
if (req.method === 'POST') {
|
||||||
|
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');
|
||||||
|
}
|
||||||
|
|
||||||
|
return await transport.handlePostMessage(req, res);
|
||||||
|
} else if (req.method === 'GET') {
|
||||||
|
const transport = new SSEServerTransport('/sse', res);
|
||||||
|
sessions.set(transport.sessionId, transport);
|
||||||
|
const server = await serverList.create();
|
||||||
|
res.on('close', () => {
|
||||||
|
sessions.delete(transport.sessionId);
|
||||||
|
serverList.close(server).catch(e => {
|
||||||
|
// eslint-disable-next-line no-console
|
||||||
|
console.error(e);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
return await server.connect(transport);
|
||||||
|
}
|
||||||
|
|
||||||
|
res.statusCode = 405;
|
||||||
|
res.end('Method not allowed');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleStreamable(req: http.IncomingMessage, res: http.ServerResponse, serverList: ServerList, sessions: Map<string, StreamableHTTPServerTransport>) {
|
||||||
|
const sessionId = req.headers['mcp-session-id'] as string | undefined;
|
||||||
|
if (sessionId) {
|
||||||
|
const transport = sessions.get(sessionId);
|
||||||
|
if (!transport) {
|
||||||
|
res.statusCode = 404;
|
||||||
|
res.end('Session not found');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
return await transport.handleRequest(req, res);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (req.method === 'POST') {
|
||||||
|
const transport = new StreamableHTTPServerTransport({
|
||||||
|
sessionIdGenerator: () => crypto.randomUUID(),
|
||||||
|
onsessioninitialized: sessionId => {
|
||||||
|
sessions.set(sessionId, transport);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
transport.onclose = () => {
|
||||||
|
if (transport.sessionId)
|
||||||
|
sessions.delete(transport.sessionId);
|
||||||
|
};
|
||||||
|
const server = await serverList.create();
|
||||||
|
await server.connect(transport);
|
||||||
|
return await transport.handleRequest(req, res);
|
||||||
|
}
|
||||||
|
|
||||||
|
res.statusCode = 400;
|
||||||
|
res.end('Invalid request');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function startHttpTransport(port: number, hostname: string | undefined, serverList: ServerList) {
|
||||||
|
const sseSessions = new Map<string, SSEServerTransport>();
|
||||||
|
const streamableSessions = new Map<string, StreamableHTTPServerTransport>();
|
||||||
|
const httpServer = http.createServer(async (req, res) => {
|
||||||
|
const url = new URL(`http://localhost${req.url}`);
|
||||||
|
if (url.pathname.startsWith('/mcp'))
|
||||||
|
await handleStreamable(req, res, serverList, streamableSessions);
|
||||||
|
else
|
||||||
|
await handleSSE(req, res, url, serverList, sseSessions);
|
||||||
|
});
|
||||||
|
httpServer.listen(port, hostname, () => {
|
||||||
|
const address = httpServer.address();
|
||||||
|
assert(address, 'Could not bind server socket');
|
||||||
|
let url: string;
|
||||||
|
if (typeof address === 'string') {
|
||||||
|
url = address;
|
||||||
|
} else {
|
||||||
|
const resolvedPort = address.port;
|
||||||
|
let resolvedHost = address.family === 'IPv4' ? address.address : `[${address.address}]`;
|
||||||
|
if (resolvedHost === '0.0.0.0' || resolvedHost === '[::]')
|
||||||
|
resolvedHost = 'localhost';
|
||||||
|
url = `http://${resolvedHost}:${resolvedPort}`;
|
||||||
|
}
|
||||||
|
const message = [
|
||||||
|
`Listening on ${url}`,
|
||||||
|
'Put this in your client config:',
|
||||||
|
JSON.stringify({
|
||||||
|
'mcpServers': {
|
||||||
|
'playwright': {
|
||||||
|
'url': `${url}/sse`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, undefined, 2),
|
||||||
|
'If your client supports streamable HTTP, you can use the /mcp endpoint instead.',
|
||||||
|
].join('\n');
|
||||||
|
// eslint-disable-next-line no-console
|
||||||
|
console.log(message);
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -14,7 +14,7 @@
|
|||||||
* limitations under the License.
|
* limitations under the License.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { test, expect } from './fixtures';
|
import { test, expect } from './fixtures.js';
|
||||||
|
|
||||||
test('test snapshot tool list', async ({ client }) => {
|
test('test snapshot tool list', async ({ client }) => {
|
||||||
const { tools } = await client.listTools();
|
const { tools } = await client.listTools();
|
||||||
@@ -23,6 +23,7 @@ test('test snapshot tool list', async ({ client }) => {
|
|||||||
'browser_console_messages',
|
'browser_console_messages',
|
||||||
'browser_drag',
|
'browser_drag',
|
||||||
'browser_file_upload',
|
'browser_file_upload',
|
||||||
|
'browser_generate_playwright_test',
|
||||||
'browser_handle_dialog',
|
'browser_handle_dialog',
|
||||||
'browser_hover',
|
'browser_hover',
|
||||||
'browser_select_option',
|
'browser_select_option',
|
||||||
@@ -32,6 +33,7 @@ test('test snapshot tool list', async ({ client }) => {
|
|||||||
'browser_navigate_back',
|
'browser_navigate_back',
|
||||||
'browser_navigate_forward',
|
'browser_navigate_forward',
|
||||||
'browser_navigate',
|
'browser_navigate',
|
||||||
|
'browser_network_requests',
|
||||||
'browser_pdf_save',
|
'browser_pdf_save',
|
||||||
'browser_press_key',
|
'browser_press_key',
|
||||||
'browser_resize',
|
'browser_resize',
|
||||||
@@ -51,11 +53,13 @@ test('test vision tool list', async ({ visionClient }) => {
|
|||||||
'browser_close',
|
'browser_close',
|
||||||
'browser_console_messages',
|
'browser_console_messages',
|
||||||
'browser_file_upload',
|
'browser_file_upload',
|
||||||
|
'browser_generate_playwright_test',
|
||||||
'browser_handle_dialog',
|
'browser_handle_dialog',
|
||||||
'browser_install',
|
'browser_install',
|
||||||
'browser_navigate_back',
|
'browser_navigate_back',
|
||||||
'browser_navigate_forward',
|
'browser_navigate_forward',
|
||||||
'browser_navigate',
|
'browser_navigate',
|
||||||
|
'browser_network_requests',
|
||||||
'browser_pdf_save',
|
'browser_pdf_save',
|
||||||
'browser_press_key',
|
'browser_press_key',
|
||||||
'browser_resize',
|
'browser_resize',
|
||||||
@@ -72,11 +76,6 @@ test('test vision tool list', async ({ visionClient }) => {
|
|||||||
]));
|
]));
|
||||||
});
|
});
|
||||||
|
|
||||||
test('test resources list', async ({ client }) => {
|
|
||||||
const { resources } = await client.listResources();
|
|
||||||
expect(resources).toEqual([]);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('test capabilities', async ({ startClient }) => {
|
test('test capabilities', async ({ startClient }) => {
|
||||||
const client = await startClient({
|
const client = await startClient({
|
||||||
args: ['--caps="core"'],
|
args: ['--caps="core"'],
|
||||||
|
|||||||
@@ -14,20 +14,20 @@
|
|||||||
* limitations under the License.
|
* limitations under the License.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { test, expect } from './fixtures';
|
import { test, expect } from './fixtures.js';
|
||||||
|
|
||||||
test('cdp server', async ({ cdpEndpoint, startClient }) => {
|
test('cdp server', async ({ cdpEndpoint, startClient }) => {
|
||||||
const client = await startClient({ args: [`--cdp-endpoint=${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: 'data:text/html,<html><title>Title</title><body>Hello, world!</body></html>',
|
url: 'data:text/html,<html><title>Title</title><body>Hello, world!</body></html>',
|
||||||
},
|
},
|
||||||
})).toContainTextContent(`- text: Hello, world!`);
|
})).toContainTextContent(`- generic [ref=s1e2]: Hello, world!`);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('cdp server reuse tab', async ({ cdpEndpoint, startClient }) => {
|
test('cdp server reuse tab', async ({ cdpEndpoint, startClient }) => {
|
||||||
const client = await startClient({ args: [`--cdp-endpoint=${cdpEndpoint}`] });
|
const client = await startClient({ args: [`--cdp-endpoint=${await cdpEndpoint()}`] });
|
||||||
|
|
||||||
expect(await client.callTool({
|
expect(await client.callTool({
|
||||||
name: 'browser_click',
|
name: 'browser_click',
|
||||||
@@ -50,7 +50,25 @@ test('cdp server reuse tab', async ({ cdpEndpoint, startClient }) => {
|
|||||||
- Page Title:
|
- Page Title:
|
||||||
- Page Snapshot
|
- Page Snapshot
|
||||||
\`\`\`yaml
|
\`\`\`yaml
|
||||||
- text: hello world
|
- generic [ref=s1e2]: hello world
|
||||||
\`\`\`
|
\`\`\`
|
||||||
`);
|
`);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('should throw connection error and allow re-connecting', async ({ cdpEndpoint, startClient }) => {
|
||||||
|
const port = 3200 + test.info().parallelIndex;
|
||||||
|
const client = await startClient({ args: [`--cdp-endpoint=http://localhost:${port}`] });
|
||||||
|
expect(await client.callTool({
|
||||||
|
name: 'browser_navigate',
|
||||||
|
arguments: {
|
||||||
|
url: 'data:text/html,<html><title>Title</title><body>Hello, world!</body></html>',
|
||||||
|
},
|
||||||
|
})).toContainTextContent(`Error: browserType.connectOverCDP: connect ECONNREFUSED`);
|
||||||
|
await cdpEndpoint(port);
|
||||||
|
expect(await client.callTool({
|
||||||
|
name: 'browser_navigate',
|
||||||
|
arguments: {
|
||||||
|
url: 'data:text/html,<html><title>Title</title><body>Hello, world!</body></html>',
|
||||||
|
},
|
||||||
|
})).toContainTextContent(`- generic [ref=s1e2]: Hello, world!`);
|
||||||
|
});
|
||||||
|
|||||||
@@ -14,7 +14,7 @@
|
|||||||
* limitations under the License.
|
* limitations under the License.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { test, expect } from './fixtures';
|
import { test, expect } from './fixtures.js';
|
||||||
|
|
||||||
test('browser_console_messages', async ({ client }) => {
|
test('browser_console_messages', async ({ client }) => {
|
||||||
await client.callTool({
|
await client.callTool({
|
||||||
|
|||||||
@@ -14,7 +14,7 @@
|
|||||||
* limitations under the License.
|
* limitations under the License.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { test, expect } from './fixtures';
|
import { test, expect } from './fixtures.js';
|
||||||
|
|
||||||
test('browser_navigate', async ({ client }) => {
|
test('browser_navigate', async ({ client }) => {
|
||||||
expect(await client.callTool({
|
expect(await client.callTool({
|
||||||
@@ -33,7 +33,7 @@ await page.goto('data:text/html,<html><title>Title</title><body>Hello, world!</b
|
|||||||
- Page Title: Title
|
- Page Title: Title
|
||||||
- Page Snapshot
|
- Page Snapshot
|
||||||
\`\`\`yaml
|
\`\`\`yaml
|
||||||
- text: Hello, world!
|
- generic [ref=s1e2]: Hello, world!
|
||||||
\`\`\`
|
\`\`\`
|
||||||
`
|
`
|
||||||
);
|
);
|
||||||
@@ -96,8 +96,8 @@ await page.getByRole('combobox').selectOption(['bar']);
|
|||||||
- Page Snapshot
|
- Page Snapshot
|
||||||
\`\`\`yaml
|
\`\`\`yaml
|
||||||
- combobox [ref=s2e3]:
|
- combobox [ref=s2e3]:
|
||||||
- option "Foo" [ref=s2e4]
|
- option "Foo"
|
||||||
- option "Bar" [selected] [ref=s2e5]
|
- option "Bar" [selected]
|
||||||
\`\`\`
|
\`\`\`
|
||||||
`);
|
`);
|
||||||
});
|
});
|
||||||
@@ -206,5 +206,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' })).toContainTextContent('Window size: 390x780');
|
await expect.poll(() => client.callTool({ name: 'browser_snapshot', arguments: {} })).toContainTextContent('Window size: 390x780');
|
||||||
});
|
});
|
||||||
|
|||||||
43
tests/device.spec.ts
Normal file
43
tests/device.spec.ts
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
/**
|
||||||
|
* 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('--device should work', async ({ startClient, server }) => {
|
||||||
|
const client = await startClient({
|
||||||
|
args: ['--device', 'iPhone 15'],
|
||||||
|
});
|
||||||
|
|
||||||
|
server.route('/', (req, res) => {
|
||||||
|
res.writeHead(200, { 'Content-Type': 'text/html' });
|
||||||
|
res.end(`
|
||||||
|
<head>
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
</head>
|
||||||
|
<body></body>
|
||||||
|
<script>
|
||||||
|
document.body.textContent = window.innerWidth + "x" + window.innerHeight;
|
||||||
|
</script>
|
||||||
|
`);
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(await client.callTool({
|
||||||
|
name: 'browser_navigate',
|
||||||
|
arguments: {
|
||||||
|
url: server.PREFIX,
|
||||||
|
},
|
||||||
|
})).toContainTextContent(`393x659`);
|
||||||
|
});
|
||||||
@@ -14,7 +14,7 @@
|
|||||||
* limitations under the License.
|
* limitations under the License.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { test, expect } from './fixtures';
|
import { test, expect } from './fixtures.js';
|
||||||
|
|
||||||
// https://github.com/microsoft/playwright/issues/35663
|
// https://github.com/microsoft/playwright/issues/35663
|
||||||
test.skip(({ mcpBrowser, mcpHeadless }) => mcpBrowser === 'webkit' && mcpHeadless);
|
test.skip(({ mcpBrowser, mcpHeadless }) => mcpBrowser === 'webkit' && mcpHeadless);
|
||||||
@@ -126,7 +126,7 @@ test('confirm dialog (true)', async ({ client }) => {
|
|||||||
expect(result).toContainTextContent('// <internal code to handle "confirm" dialog>');
|
expect(result).toContainTextContent('// <internal code to handle "confirm" dialog>');
|
||||||
expect(result).toContainTextContent(`- Page Snapshot
|
expect(result).toContainTextContent(`- Page Snapshot
|
||||||
\`\`\`yaml
|
\`\`\`yaml
|
||||||
- text: "true"
|
- generic [ref=s2e2]: "true"
|
||||||
\`\`\``);
|
\`\`\``);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -156,7 +156,7 @@ test('confirm dialog (false)', async ({ client }) => {
|
|||||||
|
|
||||||
expect(result).toContainTextContent(`- Page Snapshot
|
expect(result).toContainTextContent(`- Page Snapshot
|
||||||
\`\`\`yaml
|
\`\`\`yaml
|
||||||
- text: "false"
|
- generic [ref=s2e2]: "false"
|
||||||
\`\`\``);
|
\`\`\``);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -187,6 +187,6 @@ test('prompt dialog', async ({ client }) => {
|
|||||||
|
|
||||||
expect(result).toContainTextContent(`- Page Snapshot
|
expect(result).toContainTextContent(`- Page Snapshot
|
||||||
\`\`\`yaml
|
\`\`\`yaml
|
||||||
- text: Answer
|
- generic [ref=s2e2]: Answer
|
||||||
\`\`\``);
|
\`\`\``);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -14,8 +14,9 @@
|
|||||||
* limitations under the License.
|
* limitations under the License.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { test, expect } from './fixtures';
|
import { test, expect } from './fixtures.js';
|
||||||
import fs from 'fs/promises';
|
import fs from 'fs/promises';
|
||||||
|
import path from 'path';
|
||||||
|
|
||||||
test('browser_file_upload', async ({ client }) => {
|
test('browser_file_upload', async ({ client }) => {
|
||||||
expect(await client.callTool({
|
expect(await client.callTool({
|
||||||
@@ -23,7 +24,22 @@ test('browser_file_upload', async ({ client }) => {
|
|||||||
arguments: {
|
arguments: {
|
||||||
url: 'data:text/html,<html><title>Title</title><input type="file" /><button>Button</button></html>',
|
url: 'data:text/html,<html><title>Title</title><input type="file" /><button>Button</button></html>',
|
||||||
},
|
},
|
||||||
})).toContainTextContent('- textbox [ref=s1e3]');
|
})).toContainTextContent(`
|
||||||
|
\`\`\`yaml
|
||||||
|
- button "Choose File" [ref=s1e3]
|
||||||
|
- button "Button" [ref=s1e4]
|
||||||
|
\`\`\``);
|
||||||
|
|
||||||
|
{
|
||||||
|
expect(await client.callTool({
|
||||||
|
name: 'browser_file_upload',
|
||||||
|
arguments: { paths: [] },
|
||||||
|
})).toHaveTextContent(`
|
||||||
|
The tool "browser_file_upload" can only be used when there is related modal state present.
|
||||||
|
### Modal state
|
||||||
|
- There is no modal state present
|
||||||
|
`.trim());
|
||||||
|
}
|
||||||
|
|
||||||
expect(await client.callTool({
|
expect(await client.callTool({
|
||||||
name: 'browser_click',
|
name: 'browser_click',
|
||||||
@@ -46,7 +62,11 @@ test('browser_file_upload', async ({ client }) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
expect(response).not.toContainTextContent('### Modal state');
|
expect(response).not.toContainTextContent('### Modal state');
|
||||||
expect(response).toContainTextContent('textbox [ref=s3e3]: C:\\fakepath\\test.txt');
|
expect(response).toContainTextContent(`
|
||||||
|
\`\`\`yaml
|
||||||
|
- button "Choose File" [ref=s3e3]
|
||||||
|
- button "Button" [ref=s3e4]
|
||||||
|
\`\`\``);
|
||||||
}
|
}
|
||||||
|
|
||||||
{
|
{
|
||||||
@@ -75,3 +95,27 @@ test('browser_file_upload', async ({ client }) => {
|
|||||||
- [File chooser]: can be handled by the "browser_file_upload" tool`);
|
- [File chooser]: can be handled by the "browser_file_upload" tool`);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('clicking on download link emits download', async ({ startClient }, testInfo) => {
|
||||||
|
const outputDir = testInfo.outputPath('output');
|
||||||
|
const client = await startClient({
|
||||||
|
config: { outputDir },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(await client.callTool({
|
||||||
|
name: 'browser_navigate',
|
||||||
|
arguments: {
|
||||||
|
url: 'data:text/html,<a href="data:text/plain,Hello world!" download="test.txt">Download</a>',
|
||||||
|
},
|
||||||
|
})).toContainTextContent('- link "Download" [ref=s1e3]');
|
||||||
|
await client.callTool({
|
||||||
|
name: 'browser_click',
|
||||||
|
arguments: {
|
||||||
|
element: 'Download link',
|
||||||
|
ref: 's1e3',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await expect.poll(() => client.callTool({ name: 'browser_snapshot', arguments: {} })).toContainTextContent(`
|
||||||
|
### Downloads
|
||||||
|
- Downloaded file test.txt to ${path.join(outputDir, 'test.txt')}`);
|
||||||
|
});
|
||||||
|
|||||||
@@ -14,28 +14,39 @@
|
|||||||
* limitations under the License.
|
* limitations under the License.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import fs from 'fs';
|
||||||
|
import url from 'url';
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
import { chromium } from 'playwright';
|
import { chromium } from 'playwright';
|
||||||
|
|
||||||
import { test as baseTest, expect as baseExpect } from '@playwright/test';
|
import { test as baseTest, expect as baseExpect } from '@playwright/test';
|
||||||
import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js';
|
import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js';
|
||||||
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
|
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
|
||||||
import { spawn } from 'child_process';
|
import { ChildProcessWithoutNullStreams, spawn } from 'child_process';
|
||||||
|
import { TestServer } from './testserver/index.ts';
|
||||||
|
|
||||||
|
import type { Config } from '../config';
|
||||||
|
|
||||||
|
export type TestOptions = {
|
||||||
|
mcpBrowser: string | undefined;
|
||||||
|
};
|
||||||
|
|
||||||
type TestFixtures = {
|
type TestFixtures = {
|
||||||
client: Client;
|
client: Client;
|
||||||
visionClient: Client;
|
visionClient: Client;
|
||||||
startClient: (options?: { args?: string[] }) => Promise<Client>;
|
startClient: (options?: { args?: string[], config?: Config }) => Promise<Client>;
|
||||||
wsEndpoint: string;
|
wsEndpoint: string;
|
||||||
cdpEndpoint: string;
|
cdpEndpoint: (port?: number) => Promise<string>;
|
||||||
|
server: TestServer;
|
||||||
|
httpsServer: TestServer;
|
||||||
|
mcpHeadless: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
type WorkerFixtures = {
|
type WorkerFixtures = {
|
||||||
mcpHeadless: boolean;
|
_workerServers: { server: TestServer, httpsServer: TestServer };
|
||||||
mcpBrowser: string | undefined;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export const test = baseTest.extend<TestFixtures, WorkerFixtures>({
|
export const test = baseTest.extend<TestFixtures & TestOptions, WorkerFixtures>({
|
||||||
|
|
||||||
client: async ({ startClient }, use) => {
|
client: async ({ startClient }, use) => {
|
||||||
await use(await startClient());
|
await use(await startClient());
|
||||||
@@ -47,9 +58,9 @@ export const test = baseTest.extend<TestFixtures, WorkerFixtures>({
|
|||||||
|
|
||||||
startClient: async ({ mcpHeadless, mcpBrowser }, use, testInfo) => {
|
startClient: async ({ mcpHeadless, mcpBrowser }, use, testInfo) => {
|
||||||
const userDataDir = testInfo.outputPath('user-data-dir');
|
const userDataDir = testInfo.outputPath('user-data-dir');
|
||||||
let client: StdioClientTransport | undefined;
|
let client: Client | undefined;
|
||||||
|
|
||||||
use(async options => {
|
await use(async options => {
|
||||||
const args = ['--user-data-dir', userDataDir];
|
const args = ['--user-data-dir', userDataDir];
|
||||||
if (mcpHeadless)
|
if (mcpHeadless)
|
||||||
args.push('--headless');
|
args.push('--headless');
|
||||||
@@ -57,11 +68,18 @@ export const test = baseTest.extend<TestFixtures, WorkerFixtures>({
|
|||||||
args.push(`--browser=${mcpBrowser}`);
|
args.push(`--browser=${mcpBrowser}`);
|
||||||
if (options?.args)
|
if (options?.args)
|
||||||
args.push(...options.args);
|
args.push(...options.args);
|
||||||
|
if (options?.config) {
|
||||||
|
const configFile = testInfo.outputPath('config.json');
|
||||||
|
await fs.promises.writeFile(configFile, JSON.stringify(options.config, null, 2));
|
||||||
|
args.push(`--config=${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({
|
const transport = new StdioClientTransport({
|
||||||
command: 'node',
|
command: 'node',
|
||||||
args: [path.join(__dirname, '../cli.js'), ...args],
|
args: [path.join(path.dirname(__filename), '../cli.js'), ...args],
|
||||||
});
|
});
|
||||||
const client = new Client({ name: 'test', version: '1.0.0' });
|
client = new Client({ name: 'test', version: '1.0.0' });
|
||||||
await client.connect(transport);
|
await client.connect(transport);
|
||||||
await client.ping();
|
await client.ping();
|
||||||
return client;
|
return client;
|
||||||
@@ -77,33 +95,65 @@ export const test = baseTest.extend<TestFixtures, WorkerFixtures>({
|
|||||||
},
|
},
|
||||||
|
|
||||||
cdpEndpoint: async ({ }, use, testInfo) => {
|
cdpEndpoint: async ({ }, use, testInfo) => {
|
||||||
const port = 3200 + (+process.env.TEST_PARALLEL_INDEX!);
|
let browserProcess: ChildProcessWithoutNullStreams | undefined;
|
||||||
const executablePath = chromium.executablePath();
|
|
||||||
const browserProcess = spawn(executablePath, [
|
await use(async port => {
|
||||||
`--user-data-dir=${testInfo.outputPath('user-data-dir')}`,
|
if (!port)
|
||||||
`--remote-debugging-port=${port}`,
|
port = 3200 + test.info().parallelIndex;
|
||||||
`--no-first-run`,
|
if (browserProcess)
|
||||||
`--no-sandbox`,
|
return `http://localhost:${port}`;
|
||||||
`--headless`,
|
browserProcess = spawn(chromium.executablePath(), [
|
||||||
`data:text/html,hello world`,
|
`--user-data-dir=${testInfo.outputPath('user-data-dir')}`,
|
||||||
], {
|
`--remote-debugging-port=${port}`,
|
||||||
stdio: 'pipe',
|
`--no-first-run`,
|
||||||
});
|
`--no-sandbox`,
|
||||||
await new Promise<void>(resolve => {
|
`--headless`,
|
||||||
browserProcess.stderr.on('data', data => {
|
'--use-mock-keychain',
|
||||||
if (data.toString().includes('DevTools listening on '))
|
`data:text/html,hello world`,
|
||||||
resolve();
|
], {
|
||||||
|
stdio: 'pipe',
|
||||||
});
|
});
|
||||||
|
await new Promise<void>(resolve => {
|
||||||
|
browserProcess!.stderr.on('data', data => {
|
||||||
|
if (data.toString().includes('DevTools listening on '))
|
||||||
|
resolve();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
return `http://localhost:${port}`;
|
||||||
});
|
});
|
||||||
await use(`http://localhost:${port}`);
|
browserProcess?.kill();
|
||||||
browserProcess.kill();
|
|
||||||
},
|
},
|
||||||
|
|
||||||
mcpHeadless: [async ({ headless }, use) => {
|
mcpHeadless: async ({ headless }, use) => {
|
||||||
await use(headless);
|
await use(headless);
|
||||||
|
},
|
||||||
|
|
||||||
|
mcpBrowser: ['chrome', { option: true }],
|
||||||
|
|
||||||
|
_workerServers: [async ({}, use, workerInfo) => {
|
||||||
|
const port = 8907 + workerInfo.workerIndex * 4;
|
||||||
|
const server = await TestServer.create(port);
|
||||||
|
|
||||||
|
const httpsPort = port + 1;
|
||||||
|
const httpsServer = await TestServer.createHTTPS(httpsPort);
|
||||||
|
|
||||||
|
await use({ server, httpsServer });
|
||||||
|
|
||||||
|
await Promise.all([
|
||||||
|
server.stop(),
|
||||||
|
httpsServer.stop(),
|
||||||
|
]);
|
||||||
}, { scope: 'worker' }],
|
}, { scope: 'worker' }],
|
||||||
|
|
||||||
mcpBrowser: ['chromium', { option: true, scope: 'worker' }],
|
server: async ({ _workerServers }, use) => {
|
||||||
|
_workerServers.server.reset();
|
||||||
|
await use(_workerServers.server);
|
||||||
|
},
|
||||||
|
|
||||||
|
httpsServer: async ({ _workerServers }, use) => {
|
||||||
|
_workerServers.httpsServer.reset();
|
||||||
|
await use(_workerServers.httpsServer);
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
type Response = Awaited<ReturnType<Client['callTool']>>;
|
type Response = Awaited<ReturnType<Client['callTool']>>;
|
||||||
|
|||||||
49
tests/headed.spec.ts
Normal file
49
tests/headed.spec.ts
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
/**
|
||||||
|
* Copyright (c) Microsoft Corporation.
|
||||||
|
*
|
||||||
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
* you may not use this file except in compliance with the License.
|
||||||
|
* You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing, software
|
||||||
|
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
* See the License for the specific language governing permissions and
|
||||||
|
* limitations under the License.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { test, expect } from './fixtures.js';
|
||||||
|
|
||||||
|
for (const mcpHeadless of [false, true]) {
|
||||||
|
test.describe(`mcpHeadless: ${mcpHeadless}`, () => {
|
||||||
|
test.use({ mcpHeadless });
|
||||||
|
test.skip(process.platform === 'linux', 'Auto-detection wont let this test run on linux');
|
||||||
|
test('browser', async ({ client, server, mcpBrowser }) => {
|
||||||
|
test.skip(!['chrome', 'msedge', 'chromium'].includes(mcpBrowser ?? ''), 'Only chrome is supported for this test');
|
||||||
|
server.route('/', (req, res) => {
|
||||||
|
res.writeHead(200, { 'Content-Type': 'text/html' });
|
||||||
|
res.end(`
|
||||||
|
<body></body>
|
||||||
|
<script>
|
||||||
|
document.body.textContent = navigator.userAgent;
|
||||||
|
</script>
|
||||||
|
`);
|
||||||
|
});
|
||||||
|
|
||||||
|
const response = await client.callTool({
|
||||||
|
name: 'browser_navigate',
|
||||||
|
arguments: {
|
||||||
|
url: server.PREFIX,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response).toContainTextContent(`Mozilla/5.0`);
|
||||||
|
if (mcpHeadless)
|
||||||
|
expect(response).toContainTextContent(`HeadlessChrome`);
|
||||||
|
else
|
||||||
|
expect(response).not.toContainTextContent(`HeadlessChrome`);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -14,7 +14,7 @@
|
|||||||
* limitations under the License.
|
* limitations under the License.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { test, expect } from './fixtures';
|
import { test, expect } from './fixtures.js';
|
||||||
|
|
||||||
test('stitched aria frames', async ({ client }) => {
|
test('stitched aria frames', async ({ client }) => {
|
||||||
expect(await client.callTool({
|
expect(await client.callTool({
|
||||||
|
|||||||
@@ -14,7 +14,7 @@
|
|||||||
* limitations under the License.
|
* limitations under the License.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { test, expect } from './fixtures';
|
import { test, expect } from './fixtures.js';
|
||||||
|
|
||||||
test('test reopen browser', async ({ client }) => {
|
test('test reopen browser', async ({ client }) => {
|
||||||
await client.callTool({
|
await client.callTool({
|
||||||
@@ -26,6 +26,7 @@ test('test reopen browser', async ({ client }) => {
|
|||||||
|
|
||||||
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({
|
||||||
@@ -33,7 +34,7 @@ test('test reopen browser', async ({ client }) => {
|
|||||||
arguments: {
|
arguments: {
|
||||||
url: 'data:text/html,<html><title>Title</title><body>Hello, world!</body></html>',
|
url: 'data:text/html,<html><title>Title</title><body>Hello, world!</body></html>',
|
||||||
},
|
},
|
||||||
})).toContainTextContent(`- text: Hello, world!`);
|
})).toContainTextContent(`- generic [ref=s1e2]: Hello, world!`);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('executable path', async ({ startClient }) => {
|
test('executable path', async ({ startClient }) => {
|
||||||
|
|||||||
49
tests/network.spec.ts
Normal file
49
tests/network.spec.ts
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
/**
|
||||||
|
* 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_network_requests', async ({ client, server }) => {
|
||||||
|
server.route('/', (req, res) => {
|
||||||
|
res.writeHead(200, { 'Content-Type': 'text/html' });
|
||||||
|
res.end(`<button onclick="fetch('/json')">Click me</button>`);
|
||||||
|
});
|
||||||
|
|
||||||
|
server.route('/json', (req, res) => {
|
||||||
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||||
|
res.end(JSON.stringify({ name: 'John Doe' }));
|
||||||
|
});
|
||||||
|
|
||||||
|
await client.callTool({
|
||||||
|
name: 'browser_navigate',
|
||||||
|
arguments: {
|
||||||
|
url: server.PREFIX,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await client.callTool({
|
||||||
|
name: 'browser_click',
|
||||||
|
arguments: {
|
||||||
|
element: 'Click me button',
|
||||||
|
ref: 's1e3',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect.poll(() => client.callTool({
|
||||||
|
name: 'browser_network_requests',
|
||||||
|
arguments: {},
|
||||||
|
})).toHaveTextContent(`[GET] http://localhost:${server.PORT}/json => [200] OK`);
|
||||||
|
});
|
||||||
@@ -14,7 +14,7 @@
|
|||||||
* limitations under the License.
|
* limitations under the License.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { test, expect } from './fixtures';
|
import { test, expect } from './fixtures.js';
|
||||||
|
|
||||||
test('save as pdf unavailable', async ({ startClient }) => {
|
test('save as pdf unavailable', async ({ startClient }) => {
|
||||||
const client = await startClient({ args: ['--caps="no-pdf"'] });
|
const client = await startClient({ args: ['--caps="no-pdf"'] });
|
||||||
@@ -37,10 +37,11 @@ test('save as pdf', async ({ client, mcpBrowser }) => {
|
|||||||
arguments: {
|
arguments: {
|
||||||
url: 'data:text/html,<html><title>Title</title><body>Hello, world!</body></html>',
|
url: 'data:text/html,<html><title>Title</title><body>Hello, world!</body></html>',
|
||||||
},
|
},
|
||||||
})).toContainTextContent(`- text: 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/);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -14,7 +14,9 @@
|
|||||||
* limitations under the License.
|
* limitations under the License.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { test, expect } from './fixtures';
|
import fs from 'fs';
|
||||||
|
|
||||||
|
import { test, expect } from './fixtures.js';
|
||||||
|
|
||||||
test('browser_take_screenshot (viewport)', async ({ client }) => {
|
test('browser_take_screenshot (viewport)', async ({ client }) => {
|
||||||
expect(await client.callTool({
|
expect(await client.callTool({
|
||||||
@@ -70,3 +72,60 @@ test('browser_take_screenshot (element)', async ({ client }) => {
|
|||||||
],
|
],
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('browser_take_screenshot (outputDir)', async ({ startClient }, testInfo) => {
|
||||||
|
const outputDir = testInfo.outputPath('output');
|
||||||
|
const client = await startClient({
|
||||||
|
config: { outputDir },
|
||||||
|
});
|
||||||
|
expect(await client.callTool({
|
||||||
|
name: 'browser_navigate',
|
||||||
|
arguments: {
|
||||||
|
url: 'data:text/html,<html><title>Title</title><body>Hello, world!</body></html>',
|
||||||
|
},
|
||||||
|
})).toContainTextContent(`Navigate to data:text/html`);
|
||||||
|
|
||||||
|
await client.callTool({
|
||||||
|
name: 'browser_take_screenshot',
|
||||||
|
arguments: {},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(fs.existsSync(outputDir)).toBeTruthy();
|
||||||
|
expect([...fs.readdirSync(outputDir)]).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('browser_take_screenshot (omitBase64)', async ({ startClient }) => {
|
||||||
|
const client = await startClient({
|
||||||
|
config: {
|
||||||
|
tools: {
|
||||||
|
browser_take_screenshot: {
|
||||||
|
omitBase64: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(await client.callTool({
|
||||||
|
name: 'browser_navigate',
|
||||||
|
arguments: {
|
||||||
|
url: 'data:text/html,<html><title>Title</title><body>Hello, world!</body></html>',
|
||||||
|
},
|
||||||
|
})).toContainTextContent(`Navigate to data:text/html`);
|
||||||
|
|
||||||
|
await client.callTool({
|
||||||
|
name: 'browser_take_screenshot',
|
||||||
|
arguments: {},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(await client.callTool({
|
||||||
|
name: 'browser_take_screenshot',
|
||||||
|
arguments: {},
|
||||||
|
})).toEqual({
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
text: expect.stringContaining(`Screenshot viewport and save it as`),
|
||||||
|
type: 'text',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -14,29 +14,51 @@
|
|||||||
* limitations under the License.
|
* limitations under the License.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import url from 'node:url';
|
||||||
import { spawn } from 'node:child_process';
|
import { spawn } from 'node:child_process';
|
||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
import { test } from './fixtures';
|
import { test as baseTest } from './fixtures.js';
|
||||||
|
import { expect } from 'playwright/test';
|
||||||
|
|
||||||
test('sse transport', async () => {
|
// NOTE: Can be removed when we drop Node.js 18 support and changed to import.meta.filename.
|
||||||
const cp = spawn('node', [path.join(__dirname, '../cli.js'), '--port', '0'], { stdio: 'pipe' });
|
const __filename = url.fileURLToPath(import.meta.url);
|
||||||
try {
|
|
||||||
let stdout = '';
|
|
||||||
const url = await new Promise<string>(resolve => cp.stdout?.on('data', data => {
|
|
||||||
stdout += data.toString();
|
|
||||||
const match = stdout.match(/Listening on (http:\/\/.*)/);
|
|
||||||
if (match)
|
|
||||||
resolve(match[1]);
|
|
||||||
}));
|
|
||||||
|
|
||||||
// need dynamic import b/c of some ESM nonsense
|
const test = baseTest.extend<{ serverEndpoint: string }>({
|
||||||
const { SSEClientTransport } = await import('@modelcontextprotocol/sdk/client/sse.js');
|
serverEndpoint: async ({}, use) => {
|
||||||
const { Client } = await import('@modelcontextprotocol/sdk/client/index.js');
|
const cp = spawn('node', [path.join(path.dirname(__filename), '../cli.js'), '--port', '0'], { stdio: 'pipe' });
|
||||||
const transport = new SSEClientTransport(new URL(url));
|
try {
|
||||||
const client = new Client({ name: 'test', version: '1.0.0' });
|
let stdout = '';
|
||||||
await client.connect(transport);
|
const url = await new Promise<string>(resolve => cp.stdout?.on('data', data => {
|
||||||
await client.ping();
|
stdout += data.toString();
|
||||||
} finally {
|
const match = stdout.match(/Listening on (http:\/\/.*)/);
|
||||||
cp.kill();
|
if (match)
|
||||||
}
|
resolve(match[1]);
|
||||||
|
}));
|
||||||
|
|
||||||
|
await use(url);
|
||||||
|
} finally {
|
||||||
|
cp.kill();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
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 client = new Client({ name: 'test', version: '1.0.0' });
|
||||||
|
await client.connect(transport);
|
||||||
|
await client.ping();
|
||||||
|
});
|
||||||
|
|
||||||
|
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 client = new Client({ name: 'test', version: '1.0.0' });
|
||||||
|
await client.connect(transport);
|
||||||
|
await client.ping();
|
||||||
|
expect(transport.sessionId, 'has session support').toBeDefined();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -16,7 +16,7 @@
|
|||||||
|
|
||||||
import { chromium } from 'playwright';
|
import { chromium } from 'playwright';
|
||||||
|
|
||||||
import { test, expect } from './fixtures';
|
import { test, expect } from './fixtures.js';
|
||||||
|
|
||||||
import type { Client } from '@modelcontextprotocol/sdk/client/index.js';
|
import type { Client } from '@modelcontextprotocol/sdk/client/index.js';
|
||||||
|
|
||||||
@@ -32,6 +32,7 @@ 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)`);
|
||||||
});
|
});
|
||||||
@@ -40,6 +41,7 @@ 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>)`);
|
||||||
@@ -61,7 +63,7 @@ test('create new tab', async ({ client }) => {
|
|||||||
- Page Title: Tab one
|
- Page Title: Tab one
|
||||||
- Page Snapshot
|
- Page Snapshot
|
||||||
\`\`\`yaml
|
\`\`\`yaml
|
||||||
- text: Body one
|
- generic [ref=s1e2]: Body one
|
||||||
\`\`\``);
|
\`\`\``);
|
||||||
|
|
||||||
expect(await createTab(client, 'Tab two', 'Body two')).toHaveTextContent(`
|
expect(await createTab(client, 'Tab two', 'Body two')).toHaveTextContent(`
|
||||||
@@ -80,7 +82,7 @@ test('create new tab', async ({ client }) => {
|
|||||||
- Page Title: Tab two
|
- Page Title: Tab two
|
||||||
- Page Snapshot
|
- Page Snapshot
|
||||||
\`\`\`yaml
|
\`\`\`yaml
|
||||||
- text: Body two
|
- generic [ref=s1e2]: Body two
|
||||||
\`\`\``);
|
\`\`\``);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -108,7 +110,7 @@ test('select tab', async ({ client }) => {
|
|||||||
- Page Title: Tab one
|
- Page Title: Tab one
|
||||||
- Page Snapshot
|
- Page Snapshot
|
||||||
\`\`\`yaml
|
\`\`\`yaml
|
||||||
- text: Body one
|
- generic [ref=s2e2]: Body one
|
||||||
\`\`\``);
|
\`\`\``);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -135,16 +137,16 @@ test('close tab', async ({ client }) => {
|
|||||||
- Page Title: Tab one
|
- Page Title: Tab one
|
||||||
- Page Snapshot
|
- Page Snapshot
|
||||||
\`\`\`yaml
|
\`\`\`yaml
|
||||||
- text: Body one
|
- generic [ref=s2e2]: Body one
|
||||||
\`\`\``);
|
\`\`\``);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('reuse first tab when navigating', async ({ startClient, cdpEndpoint }) => {
|
test('reuse first tab when navigating', async ({ startClient, cdpEndpoint }) => {
|
||||||
const browser = await chromium.connectOverCDP(cdpEndpoint);
|
const browser = await chromium.connectOverCDP(await cdpEndpoint());
|
||||||
const [context] = browser.contexts();
|
const [context] = browser.contexts();
|
||||||
const pages = context.pages();
|
const pages = context.pages();
|
||||||
|
|
||||||
const client = await startClient({ args: [`--cdp-endpoint=${cdpEndpoint}`] });
|
const client = await startClient({ args: [`--cdp-endpoint=${await cdpEndpoint()}`] });
|
||||||
await client.callTool({
|
await client.callTool({
|
||||||
name: 'browser_navigate',
|
name: 'browser_navigate',
|
||||||
arguments: {
|
arguments: {
|
||||||
|
|||||||
29
tests/testserver/cert.pem
Normal file
29
tests/testserver/cert.pem
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
-----BEGIN CERTIFICATE-----
|
||||||
|
MIIFCjCCAvKgAwIBAgIULU/gkDm8IqC7PG8u3RID0AYyP6gwDQYJKoZIhvcNAQEL
|
||||||
|
BQAwGjEYMBYGA1UEAwwPcGxheXdyaWdodC10ZXN0MB4XDTIzMDgxMDIyNTc1MFoX
|
||||||
|
DTMzMDgwNzIyNTc1MFowGjEYMBYGA1UEAwwPcGxheXdyaWdodC10ZXN0MIICIjAN
|
||||||
|
BgkqhkiG9w0BAQEFAAOCAg8AMIICCgKCAgEArbS99qjKcnHr5G0Zc2xhDaOZnjQv
|
||||||
|
Fbiqxf/nbXt/7WaqryzpVKu7AT1ainBvuPEo7If9DhVnfF//2pGl0gbU31OU4/mr
|
||||||
|
ymQmczGEyZvOBDsZhtCif54o5OoO0BjhODNT8OWec9RT87n6RkH58MHlOi8xsPxQ
|
||||||
|
9n5U1CN/h2DyQF3aRKunEFCgtwPKWSjG+J/TAI9i0aSENXPiR8wjTrjg79s8Ehuj
|
||||||
|
NN8Wk6rKLU3sepG3GIMID5vLsVa2t9xqn562sP95Ee+Xp2YX3z7oYK99QCJdzacw
|
||||||
|
alhMHob1GCEKjDyxsD2IFRi7Dysiutfyzy3pMo6NALxFrwKVhWX0L4zVFIsI6JlV
|
||||||
|
dK8dHmDk0MRSqgB9sWXvEfSTXADEe8rncFSFpFz4Z8RNLmn5YSzQJzokNn41DUCP
|
||||||
|
dZTlTkcGTqvn5NqoY4sOV8rkFbgmTcqyijV/sebPjxCbJNcNmaSWa9FJ5IjRTpzM
|
||||||
|
38wLmxn+eKGK68n2JB3P7JP6LtsBShQEpXAF3rFfyNsP1bjquvGZVSjV8w/UwPE4
|
||||||
|
kV5eq3j3D4913Zfxvzjp6PEmhStG0EQtIXvx/TRoYpaNWypIgZdbkZQp1HUIQL15
|
||||||
|
D2Web4nazP3so1FC3ZgbrJZ2ozoadjLMp49NcSFdh+WRyVKuo0DIqR0zaiAzzf2D
|
||||||
|
G1q7TLKimM3XBMUCAwEAAaNIMEYwCQYDVR0TBAIwADALBgNVHQ8EBAMCBeAwLAYD
|
||||||
|
VR0RBCUwI4IJbG9jYWxob3N0hwR/AAABhxAAAAAAAAAAAAAAAAAAAAABMA0GCSqG
|
||||||
|
SIb3DQEBCwUAA4ICAQAvC5M1JFc21WVSLPvE2iVbt4HmirO3EENdDqs+rTYG5VJG
|
||||||
|
iE5ZuI6h/LjS5ptTfKovXQKaMr3pwp1pLMd/9q+6ZR1Hs9Z2wF6OZan4sb0uT32Y
|
||||||
|
1KGlj86QMiiSLdrJ/1Z9JHskHYNCep1ZTsUhGk0qqiNv+G3K2y7ZpvrT/xlnYMth
|
||||||
|
KLTuSVUwM8BBEPrCRLoXuaEy0LnvMvMVepIfP8tnMIL6zqmj3hXMPe4r4OFV/C5o
|
||||||
|
XX25bC7GyuPWIRYn2OWP92J1CODZD1rGRoDtmvqrQpHdeX9RYcKH0ZLZoIf5L3Hf
|
||||||
|
pPUtVkw3QGtjvKeG3b9usxaV9Od2Z08vKKk1PRkXFe8gqaeyicK7YVIOMTSuspAf
|
||||||
|
JeJEHns6Hg61Exbo7GwdX76xlmQ/Z43E9BPHKgLyZ9WuJ0cysqN4aCyvS9yws9to
|
||||||
|
ki7iMZqJUsmE2o09n9VaEsX6uQANZtLjI9wf+IgJuueDTNrkzQkhU7pbaPMsSG40
|
||||||
|
AgGY/y4BR0H8sbhNnhqtZH7RcXV9VCJoPBAe+YiuXRiXyZHWxwBRyBE3e7g4MKHg
|
||||||
|
hrWtaWUAs7gbavHwjqgU63iVItDSk7t4fCiEyObjK09AaNf2DjjaSGf8YGza4bNy
|
||||||
|
BjYinYJ6/eX//gp+abqfocFbBP7D9zRDgMIbVmX/Ey6TghKiLkZOdbzcpO4Wgg==
|
||||||
|
-----END CERTIFICATE-----
|
||||||
150
tests/testserver/index.ts
Normal file
150
tests/testserver/index.ts
Normal file
@@ -0,0 +1,150 @@
|
|||||||
|
/**
|
||||||
|
* Copyright 2017 Google Inc. All rights reserved.
|
||||||
|
* Modifications copyright (c) Microsoft Corporation.
|
||||||
|
*
|
||||||
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
* you may not use this file except in compliance with the License.
|
||||||
|
* You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing, software
|
||||||
|
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
* See the License for the specific language governing permissions and
|
||||||
|
* limitations under the License.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import fs from 'fs';
|
||||||
|
import url from 'node:url';
|
||||||
|
import http from 'http';
|
||||||
|
import https from 'https';
|
||||||
|
import path from 'path';
|
||||||
|
import debug from 'debug';
|
||||||
|
|
||||||
|
const fulfillSymbol = Symbol('fulfil callback');
|
||||||
|
const rejectSymbol = Symbol('reject callback');
|
||||||
|
|
||||||
|
// NOTE: Can be removed when we drop Node.js 18 support and changed to import.meta.filename.
|
||||||
|
const __filename = url.fileURLToPath(import.meta.url);
|
||||||
|
|
||||||
|
export class TestServer {
|
||||||
|
private _server: http.Server;
|
||||||
|
readonly debugServer: any;
|
||||||
|
private _routes = new Map<string, (request: http.IncomingMessage, response: http.ServerResponse) => any>();
|
||||||
|
private _csp = new Map<string, string>();
|
||||||
|
private _extraHeaders = new Map<string, object>();
|
||||||
|
private _requestSubscribers = new Map<string, Promise<any>>();
|
||||||
|
readonly PORT: number;
|
||||||
|
readonly PREFIX: string;
|
||||||
|
readonly CROSS_PROCESS_PREFIX: string;
|
||||||
|
|
||||||
|
static async create(port: number): Promise<TestServer> {
|
||||||
|
const server = new TestServer(port);
|
||||||
|
await new Promise(x => server._server.once('listening', x));
|
||||||
|
return server;
|
||||||
|
}
|
||||||
|
|
||||||
|
static async createHTTPS(port: number): Promise<TestServer> {
|
||||||
|
const server = new TestServer(port, {
|
||||||
|
key: await fs.promises.readFile(path.join(path.dirname(__filename), 'key.pem')),
|
||||||
|
cert: await fs.promises.readFile(path.join(path.dirname(__filename), 'cert.pem')),
|
||||||
|
passphrase: 'aaaa',
|
||||||
|
});
|
||||||
|
await new Promise(x => server._server.once('listening', x));
|
||||||
|
return server;
|
||||||
|
}
|
||||||
|
|
||||||
|
constructor(port: number, sslOptions?: object) {
|
||||||
|
if (sslOptions)
|
||||||
|
this._server = https.createServer(sslOptions, this._onRequest.bind(this));
|
||||||
|
else
|
||||||
|
this._server = http.createServer(this._onRequest.bind(this));
|
||||||
|
this._server.listen(port);
|
||||||
|
this.debugServer = debug('pw:testserver');
|
||||||
|
|
||||||
|
const cross_origin = '127.0.0.1';
|
||||||
|
const same_origin = 'localhost';
|
||||||
|
const protocol = sslOptions ? 'https' : 'http';
|
||||||
|
this.PORT = port;
|
||||||
|
this.PREFIX = `${protocol}://${same_origin}:${port}`;
|
||||||
|
this.CROSS_PROCESS_PREFIX = `${protocol}://${cross_origin}:${port}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
setCSP(path: string, csp: string) {
|
||||||
|
this._csp.set(path, csp);
|
||||||
|
}
|
||||||
|
|
||||||
|
setExtraHeaders(path: string, object: Record<string, string>) {
|
||||||
|
this._extraHeaders.set(path, object);
|
||||||
|
}
|
||||||
|
|
||||||
|
async stop() {
|
||||||
|
this.reset();
|
||||||
|
await new Promise(x => this._server.close(x));
|
||||||
|
}
|
||||||
|
|
||||||
|
route(path: string, handler: (request: http.IncomingMessage, response: http.ServerResponse) => any) {
|
||||||
|
this._routes.set(path, handler);
|
||||||
|
}
|
||||||
|
|
||||||
|
redirect(from: string, to: string) {
|
||||||
|
this.route(from, (req, res) => {
|
||||||
|
const headers = this._extraHeaders.get(req.url!) || {};
|
||||||
|
res.writeHead(302, { ...headers, location: to });
|
||||||
|
res.end();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
waitForRequest(path: string): Promise<http.IncomingMessage> {
|
||||||
|
let promise = this._requestSubscribers.get(path);
|
||||||
|
if (promise)
|
||||||
|
return promise;
|
||||||
|
let fulfill, reject;
|
||||||
|
promise = new Promise((f, r) => {
|
||||||
|
fulfill = f;
|
||||||
|
reject = r;
|
||||||
|
});
|
||||||
|
promise[fulfillSymbol] = fulfill;
|
||||||
|
promise[rejectSymbol] = reject;
|
||||||
|
this._requestSubscribers.set(path, promise);
|
||||||
|
return promise;
|
||||||
|
}
|
||||||
|
|
||||||
|
reset() {
|
||||||
|
this._routes.clear();
|
||||||
|
this._csp.clear();
|
||||||
|
this._extraHeaders.clear();
|
||||||
|
this._server.closeAllConnections();
|
||||||
|
const error = new Error('Static Server has been reset');
|
||||||
|
for (const subscriber of this._requestSubscribers.values())
|
||||||
|
subscriber[rejectSymbol].call(null, error);
|
||||||
|
this._requestSubscribers.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
_onRequest(request: http.IncomingMessage, response: http.ServerResponse) {
|
||||||
|
request.on('error', error => {
|
||||||
|
if ((error as any).code === 'ECONNRESET')
|
||||||
|
response.end();
|
||||||
|
else
|
||||||
|
throw error;
|
||||||
|
});
|
||||||
|
(request as any).postBody = new Promise(resolve => {
|
||||||
|
const chunks: Buffer[] = [];
|
||||||
|
request.on('data', chunk => {
|
||||||
|
chunks.push(chunk);
|
||||||
|
});
|
||||||
|
request.on('end', () => resolve(Buffer.concat(chunks)));
|
||||||
|
});
|
||||||
|
const path = request.url || '/';
|
||||||
|
this.debugServer(`request ${request.method} ${path}`);
|
||||||
|
// Notify request subscriber.
|
||||||
|
if (this._requestSubscribers.has(path)) {
|
||||||
|
this._requestSubscribers.get(path)![fulfillSymbol].call(null, request);
|
||||||
|
this._requestSubscribers.delete(path);
|
||||||
|
}
|
||||||
|
const handler = this._routes.get(path);
|
||||||
|
if (handler)
|
||||||
|
handler.call(null, request, response);
|
||||||
|
}
|
||||||
|
}
|
||||||
52
tests/testserver/key.pem
Normal file
52
tests/testserver/key.pem
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
-----BEGIN PRIVATE KEY-----
|
||||||
|
MIIJQgIBADANBgkqhkiG9w0BAQEFAASCCSwwggkoAgEAAoICAQCttL32qMpycevk
|
||||||
|
bRlzbGENo5meNC8VuKrF/+dte3/tZqqvLOlUq7sBPVqKcG+48Sjsh/0OFWd8X//a
|
||||||
|
kaXSBtTfU5Tj+avKZCZzMYTJm84EOxmG0KJ/nijk6g7QGOE4M1Pw5Z5z1FPzufpG
|
||||||
|
QfnwweU6LzGw/FD2flTUI3+HYPJAXdpEq6cQUKC3A8pZKMb4n9MAj2LRpIQ1c+JH
|
||||||
|
zCNOuODv2zwSG6M03xaTqsotTex6kbcYgwgPm8uxVra33Gqfnraw/3kR75enZhff
|
||||||
|
Puhgr31AIl3NpzBqWEwehvUYIQqMPLGwPYgVGLsPKyK61/LPLekyjo0AvEWvApWF
|
||||||
|
ZfQvjNUUiwjomVV0rx0eYOTQxFKqAH2xZe8R9JNcAMR7yudwVIWkXPhnxE0uaflh
|
||||||
|
LNAnOiQ2fjUNQI91lOVORwZOq+fk2qhjiw5XyuQVuCZNyrKKNX+x5s+PEJsk1w2Z
|
||||||
|
pJZr0UnkiNFOnMzfzAubGf54oYrryfYkHc/sk/ou2wFKFASlcAXesV/I2w/VuOq6
|
||||||
|
8ZlVKNXzD9TA8TiRXl6rePcPj3Xdl/G/OOno8SaFK0bQRC0he/H9NGhilo1bKkiB
|
||||||
|
l1uRlCnUdQhAvXkPZZ5vidrM/eyjUULdmBuslnajOhp2Msynj01xIV2H5ZHJUq6j
|
||||||
|
QMipHTNqIDPN/YMbWrtMsqKYzdcExQIDAQABAoICAGqXttpdyZ1g+vg5WpzRrNzJ
|
||||||
|
v8KtExepMmI+Hq24U1BC6AqG7MfgeejQ1XaOeIBsvEgpSsgRqmdQIZjmN3Mibg59
|
||||||
|
I6ih1SFlQ5L8mBd/XHSML6Xi8VSOoVmXp29bVRk/pgr1XL6HVN0DCumCIvXyhc+m
|
||||||
|
lj+dFbGs5DEpd2CDxSRqcz4gd2wzjevAj7MWqsJ2kOyPEHzFD7wdWIXmZuQv3xhQ
|
||||||
|
2BPkkcon+5qx+07BupOcR1brUU8Cs4QnSgiZYXSB2GnU215+P/mhVJTR7ZcnGRz5
|
||||||
|
+cXxCmy3sj4pYs1juS1FMWSM3azUeDVeqvks+vrXmXpEr5H79mbmlwo8/hMPwNDO
|
||||||
|
07HRZwa8T01aT9EYVm0lIOYjMF/2f6j6cu2apJtjXICOksR2HefRBVXQirOxRHma
|
||||||
|
9XAYfNkZ/2164ZbgFmJv9khFnegPEuth9tLVdFIeGSmsG0aX9tH63zGT2NROyyLc
|
||||||
|
QXPqsDl2CxCYPRs2oiGkM9dnfP1wAOp96sq42GIuN7ykfqfRnwAIvvnLKvyCq1vR
|
||||||
|
pIno3CIX6vnzt+1/Hrmv13b0L6pJPitpXwKWHv9zJKBTpN8HEzP3Qmth2Ef60/7/
|
||||||
|
CBo1PVTd1A6zcU7816flg7SCY+Vk+OxVHV3dGBIIqN9SfrQ8BPcOl6FNV5Anbrnv
|
||||||
|
CpSw+LzH9n5xympDnk0BAoIBAQDjenvDfCnrNVeqx8+sYaYey4/WPVLXOQhREvRY
|
||||||
|
oOtX9eqlNSi20+Wl+iuXmyj8wdHrDET7rfjCbpDQ7u105yzLw4gy4qIRDKZ1nE45
|
||||||
|
YX+tm8mZgBqRnTp0DoGOArqmp3IKXJtUYmpbTz9tOfY7Usb1o1epb4winEB+Pl+8
|
||||||
|
mgXOEo8xvWBzKeRA7tE73V64Mwbvbo9Ff2EguhXweQP29yBkEjT4iViayuHUmyPt
|
||||||
|
hOVSMj2oFQuQGPdhAk7nUXojSGK/Zas/AGpH9CHH9De0h4m08vd3oM4vj0HwzgjU
|
||||||
|
Co9aRa9SAH7EiaocOTcjDRPxWdZPHhxmrVRIYlF0MNmOAkXJAoIBAQDDfEqu4sNi
|
||||||
|
pq74VXVatQqhzCILZo+o48bdgEjF7mF99mqPj8rwIDrEoEriDK861kenLc3vWKRY
|
||||||
|
5wh1iX3S896re9kUMoxx6p4heYTcsOJ9BbkcpT8bJPZx9gBJb4jJENeVf1exf6sG
|
||||||
|
RhFnulpzReRRaUjX2yAkyUPfc8YcUt+Nalrg+2W0fzeLCUpABCAcj2B1Vv7qRZHj
|
||||||
|
oEtlCV5Nz+iMhrwIa16g9c8wGt5DZb4PI+VIJ6EYkdsjhgqIF0T/wDq9/habGBPo
|
||||||
|
mHN+/DX3hCJWN2QgoVGJskHGt0zDMgiEgXfLZ2Grl02vQtq+mW2O2vGVeUd9Y5Ew
|
||||||
|
RUiY4bSRTrUdAoIBAHxL1wiP9c/By+9TUtScXssA681ioLtdPIAgXUd4VmAvzVEM
|
||||||
|
ZPzRd/BjbCJg89p4hZ1rjN4Ax6ZmB9dCVpnEH6QPaYJ0d53dTa+CAvQzpDJWp6eq
|
||||||
|
adobEW+M5ZmVQCwD3rpus6k+RWMzQDMMstDjgDeEU0gP3YCj5FGW/3TsrDNXzMqe
|
||||||
|
8e67ey9Hzyho43K+3xFBViPhYE8jnw1Q8quliRtlH3CWi8W5CgDD7LPCJBPvw+Tt
|
||||||
|
6u2H1tQ5EKgwyw4wZVSz1wiLz4cVjMfXWADa9pHbGQFS6pbuLlfIHObQBliLLysd
|
||||||
|
ficiGcNmOAx8/uKn9gQxLc+k8iLDJkLY1mdUMpECggEAJLl87k37ltTpmg2z9k58
|
||||||
|
qNjIrIugAYKJIaOwCD84YYmhi0bgQSxM3hOe/ciUQuFupKGeRpDIj0sX87zYvoDC
|
||||||
|
HEUwCvNUHzKMco15wFwasJIarJ7+tALFqbMlaqZhdCSN27AIsXfikVMogewoge9n
|
||||||
|
bUPyQ1sPNtn4vknptfh7tv18BTg1aytbK+ua31vnDHaDEIg/a5OWTMUYZOrVpJii
|
||||||
|
f4PwX0SMioCjY84oY1EB26ZKtLt9MDh2ir3rzJVSiRl776WEaa6kTtYVHI4VNWLF
|
||||||
|
cJ0HWnnz74JliQd2jFUh9IK+FqBdYPcTyREuNxBr3KKVMBeQrqW96OubL913JrU6
|
||||||
|
oQKCAQEA0yzORUouT0yleWs7RmzBlT9OLD/3cBYJMf/r1F8z8OQjB8fU1jKbO1Cs
|
||||||
|
q4l+o9FmI+eHkgc3xbEG0hahOFWm/hTTli9vzksxurgdawZELThRkK33uTU9pKla
|
||||||
|
Okqx3Ru/iMOW2+DQUx9UB+jK+hSAgq4gGqLeJVyaBerIdLQLlvqxrwSxjvvj+wJC
|
||||||
|
Y66mgRzdCi6VDF1vV0knCrQHK6tRwcPozu/k4zjJzvdbMJnKEy2S7Vh6vO8lEPJm
|
||||||
|
MQtaHPpmz+F4z14b9unNIiSbHO60Q4O+BwIBCzxApQQbFg63vBLYYwEMRd7hh92s
|
||||||
|
ZkZVSOEp+sYBf/tmptlKr49nO+dTjQ==
|
||||||
|
-----END PRIVATE KEY-----
|
||||||
19
tests/testserver/san.cnf
Normal file
19
tests/testserver/san.cnf
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
# openssl req -new -x509 -days 3650 -key key.pem -out cert.pem -config san.cnf -extensions v3_req
|
||||||
|
|
||||||
|
[req]
|
||||||
|
distinguished_name = req_distinguished_name
|
||||||
|
req_extensions = v3_req
|
||||||
|
prompt = no
|
||||||
|
|
||||||
|
[req_distinguished_name]
|
||||||
|
CN = playwright-test
|
||||||
|
|
||||||
|
[v3_req]
|
||||||
|
basicConstraints = CA:FALSE
|
||||||
|
keyUsage = nonRepudiation, digitalSignature, keyEncipherment
|
||||||
|
subjectAltName = @alt_names
|
||||||
|
|
||||||
|
[alt_names]
|
||||||
|
DNS.1 = localhost
|
||||||
|
IP.1 = 127.0.0.1
|
||||||
|
IP.2 = ::1
|
||||||
38
tests/webdriver.spec.ts
Normal file
38
tests/webdriver.spec.ts
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
/**
|
||||||
|
* 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('do not falsely advertise user agent as a test driver', async ({ client, server, mcpBrowser }) => {
|
||||||
|
test.skip(mcpBrowser === 'firefox');
|
||||||
|
test.skip(mcpBrowser === 'webkit');
|
||||||
|
server.route('/', (req, res) => {
|
||||||
|
res.writeHead(200, { 'Content-Type': 'text/html' });
|
||||||
|
res.end(`
|
||||||
|
<body></body>
|
||||||
|
<script>
|
||||||
|
document.body.textContent = 'webdriver: ' + navigator.webdriver;
|
||||||
|
</script>
|
||||||
|
`);
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(await client.callTool({
|
||||||
|
name: 'browser_navigate',
|
||||||
|
arguments: {
|
||||||
|
url: server.PREFIX,
|
||||||
|
},
|
||||||
|
})).toContainTextContent('webdriver: false');
|
||||||
|
});
|
||||||
4
tsconfig.all.json
Normal file
4
tsconfig.all.json
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
{
|
||||||
|
"extends": "./tsconfig.json",
|
||||||
|
"include": ["**/*.ts", "**/*.js"],
|
||||||
|
}
|
||||||
@@ -3,10 +3,12 @@
|
|||||||
"target": "ESNext",
|
"target": "ESNext",
|
||||||
"skipLibCheck": true,
|
"skipLibCheck": true,
|
||||||
"esModuleInterop": true,
|
"esModuleInterop": true,
|
||||||
"moduleResolution": "node",
|
"moduleResolution": "nodenext",
|
||||||
"strict": true,
|
"strict": true,
|
||||||
"module": "CommonJS",
|
"module": "NodeNext",
|
||||||
"outDir": "./lib"
|
"rootDir": "src",
|
||||||
|
"outDir": "./lib",
|
||||||
|
"resolveJsonModule": true
|
||||||
},
|
},
|
||||||
"include": [
|
"include": [
|
||||||
"src",
|
"src",
|
||||||
|
|||||||
6
utils/generate-links.js
Normal file
6
utils/generate-links.js
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
const config = JSON.stringify({ name: 'playwright', command: 'npx', args: ["@playwright/mcp@latest"] });
|
||||||
|
const urlForWebsites = `vscode:mcp/install?${encodeURIComponent(config)}`;
|
||||||
|
// Github markdown does not allow linking to `vscode:` directly, so you can use our redirect:
|
||||||
|
const urlForGithub = `https://insiders.vscode.dev/redirect?url=${encodeURIComponent(urlForWebsites)}`;
|
||||||
|
|
||||||
|
console.log(urlForGithub);
|
||||||
@@ -16,20 +16,23 @@
|
|||||||
*/
|
*/
|
||||||
// @ts-check
|
// @ts-check
|
||||||
|
|
||||||
const fs = require('node:fs');
|
import fs from 'node:fs'
|
||||||
const path = require('node:path');
|
import path from 'node:path'
|
||||||
|
import url from 'node:url'
|
||||||
|
import zodToJsonSchema from 'zod-to-json-schema'
|
||||||
|
|
||||||
const commonTools = require('../lib/tools/common').default;
|
import commonTools from '../lib/tools/common.js';
|
||||||
const consoleTools = require('../lib/tools/console').default;
|
import consoleTools from '../lib/tools/console.js';
|
||||||
const dialogsTools = require('../lib/tools/dialogs').default;
|
import dialogsTools from '../lib/tools/dialogs.js';
|
||||||
const filesTools = require('../lib/tools/files').default;
|
import filesTools from '../lib/tools/files.js';
|
||||||
const installTools = require('../lib/tools/install').default;
|
import installTools from '../lib/tools/install.js';
|
||||||
const keyboardTools = require('../lib/tools/keyboard').default;
|
import keyboardTools from '../lib/tools/keyboard.js';
|
||||||
const navigateTools = require('../lib/tools/navigate').default;
|
import navigateTools from '../lib/tools/navigate.js';
|
||||||
const pdfTools = require('../lib/tools/pdf').default;
|
import pdfTools from '../lib/tools/pdf.js';
|
||||||
const snapshotTools = require('../lib/tools/snapshot').default;
|
import snapshotTools from '../lib/tools/snapshot.js';
|
||||||
const tabsTools = require('../lib/tools/tabs').default;
|
import tabsTools from '../lib/tools/tabs.js';
|
||||||
const screenTools = require('../lib/tools/screen').default;
|
import screenTools from '../lib/tools/screen.js';
|
||||||
|
import testTools from '../lib/tools/testing.js';
|
||||||
|
|
||||||
// Category definitions for tools
|
// Category definitions for tools
|
||||||
const categories = {
|
const categories = {
|
||||||
@@ -60,8 +63,14 @@ const categories = {
|
|||||||
...installTools,
|
...installTools,
|
||||||
...dialogsTools(true),
|
...dialogsTools(true),
|
||||||
],
|
],
|
||||||
|
'Testing': [
|
||||||
|
...testTools,
|
||||||
|
],
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// NOTE: Can be removed when we drop Node.js 18 support and changed to import.meta.filename.
|
||||||
|
const __filename = url.fileURLToPath(import.meta.url);
|
||||||
|
|
||||||
const kStartMarker = `<!--- Generated by ${path.basename(__filename)} -->`;
|
const kStartMarker = `<!--- Generated by ${path.basename(__filename)} -->`;
|
||||||
const kEndMarker = `<!--- End of generated section -->`;
|
const kEndMarker = `<!--- End of generated section -->`;
|
||||||
|
|
||||||
@@ -107,11 +116,11 @@ function formatToolForReadme(tool) {
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param {import('../src/tools/tool').ToolSchema} schema
|
* @param {import('../src/tools/tool').ToolSchema<any>} schema
|
||||||
* @returns {ParsedToolSchema}
|
* @returns {ParsedToolSchema}
|
||||||
*/
|
*/
|
||||||
function processToolSchema(schema) {
|
function processToolSchema(schema) {
|
||||||
const inputSchema = /** @type {import('zod-to-json-schema').JsonSchema7ObjectType} */ (schema.inputSchema || {});
|
const inputSchema = /** @type {import('zod-to-json-schema').JsonSchema7ObjectType} */ (zodToJsonSchema(schema.inputSchema || {}));
|
||||||
if (inputSchema.type !== 'object')
|
if (inputSchema.type !== 'object')
|
||||||
throw new Error(`Tool ${schema.name} input schema is not an object`);
|
throw new Error(`Tool ${schema.name} input schema is not an object`);
|
||||||
|
|
||||||
@@ -151,7 +160,7 @@ async function updateReadme() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const readmePath = path.join(__dirname, '..', 'README.md');
|
const readmePath = path.join(path.dirname(__filename), '..', 'README.md');
|
||||||
const readmeContent = await fs.promises.readFile(readmePath, 'utf-8');
|
const readmeContent = await fs.promises.readFile(readmePath, 'utf-8');
|
||||||
const startMarker = readmeContent.indexOf(kStartMarker);
|
const startMarker = readmeContent.indexOf(kStartMarker);
|
||||||
const endMarker = readmeContent.indexOf(kEndMarker);
|
const endMarker = readmeContent.indexOf(kEndMarker);
|
||||||
|
|||||||
Reference in New Issue
Block a user