Compare commits
29 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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
|
||||||
|
|||||||
3
.gitignore
vendored
3
.gitignore
vendored
@@ -2,3 +2,6 @@ lib/
|
|||||||
node_modules/
|
node_modules/
|
||||||
test-results/
|
test-results/
|
||||||
.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
|
||||||
|
|||||||
189
README.md
189
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,102 @@ 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
|
||||||
|
>;
|
||||||
|
|
||||||
|
// 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 +191,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 +203,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 +255,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 -->
|
||||||
|
|
||||||
|
|||||||
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';
|
||||||
|
|
||||||
|
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,7 @@ const plugins = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const baseRules = {
|
export const baseRules = {
|
||||||
|
"@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" },
|
||||||
@@ -184,6 +185,9 @@ 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 [
|
||||||
|
|||||||
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 {};
|
||||||
|
|||||||
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.18",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "@playwright/mcp",
|
"name": "@playwright/mcp",
|
||||||
"version": "0.0.14",
|
"version": "0.0.18",
|
||||||
"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-2025-04-25",
|
||||||
"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-2025-04-25",
|
||||||
"@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-2025-04-25",
|
||||||
"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-2025-04-25.tgz",
|
||||||
"integrity": "sha512-4uBgNlJ6hgPtB8DrwQsgoKuVoe7j+nPqudna7CLXWCmmT3LYPMD5aOjGoBkszr+R9NejtKashq/bOi/ny9hsIA==",
|
"integrity": "sha512-3y4C2ZjAc2oUpwavC2yG2JzH53TOKgcMZvWb5GmpxnOa6fhuSVXK0kIsiIaImKmdffIVM1agsqNHp8yldeBTHQ==",
|
||||||
"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-2025-04-25"
|
||||||
},
|
},
|
||||||
"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-2025-04-25",
|
||||||
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.52.0-alpha-1743163434000.tgz",
|
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.53.0-alpha-2025-04-25.tgz",
|
||||||
"integrity": "sha512-4uYv49ekPjolydfFfTfFQ2z4URF9UZMVUXLy7aXam/tPxEQ5O7+jQC+yzrDMGmhcj5QkMnxjlyk7N2V9a0QLdQ==",
|
"integrity": "sha512-b5VT4lWgyhhy99zHeCoUBt/FQckPxeQVA5ksvxBv0HeqcEvzZzhuyqrrcZewJyflE+5U+bmvqI+yoU0ks8mE3Q==",
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"playwright-core": "1.52.0-alpha-1743163434000"
|
"playwright-core": "1.53.0-alpha-2025-04-25"
|
||||||
},
|
},
|
||||||
"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-2025-04-25",
|
||||||
"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-2025-04-25.tgz",
|
||||||
"integrity": "sha512-Tn4u3Ywwjkh847/bYWlXIrNxv5DRJRDgtb+VYMXHvNCKkrxL6yfZ1ApIAYD7IAkkKH/KLTXszGWl3a/Z/KDfQA==",
|
"integrity": "sha512-gjV01l6A4q/zg+/pwEX50k9lhYWaE9NcDVypSDD331jB3EYrdk0LeDQxqz5XFDOzq/tC/8QTouDs9a/s/p95hA==",
|
||||||
"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"
|
||||||
|
|||||||
10
package.json
10
package.json
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@playwright/mcp",
|
"name": "@playwright/mcp",
|
||||||
"version": "0.0.14",
|
"version": "0.0.18",
|
||||||
"description": "Playwright Tools for MCP",
|
"description": "Playwright Tools for MCP",
|
||||||
"repository": {
|
"repository": {
|
||||||
"type": "git",
|
"type": "git",
|
||||||
@@ -16,7 +16,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 +34,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-2025-04-25",
|
||||||
"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-2025-04-25",
|
||||||
"@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",
|
||||||
|
|||||||
190
src/config.ts
Normal file
190
src/config.ts
Normal file
@@ -0,0 +1,190 @@
|
|||||||
|
/**
|
||||||
|
* 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 { sanitizeForFilePath } from './tools/utils';
|
||||||
|
|
||||||
|
import type { Config, ToolCapability } from '../config';
|
||||||
|
import type { BrowserContextOptions, LaunchOptions } from 'playwright';
|
||||||
|
|
||||||
|
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,
|
||||||
|
};
|
||||||
|
}
|
||||||
207
src/context.ts
207
src/context.ts
@@ -15,23 +15,14 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import * as playwright from 'playwright';
|
import * as playwright from 'playwright';
|
||||||
import yaml from 'yaml';
|
|
||||||
|
|
||||||
import { waitForCompletion } from './tools/utils';
|
import { waitForCompletion } from './tools/utils';
|
||||||
import { ManualPromise } from './manualPromise';
|
import { ManualPromise } from './manualPromise';
|
||||||
|
import { Tab } from './tab';
|
||||||
|
|
||||||
import type { ImageContent, TextContent } from '@modelcontextprotocol/sdk/types';
|
import type { ImageContent, TextContent } from '@modelcontextprotocol/sdk/types';
|
||||||
import type { ModalState, Tool, ToolActionResult } from './tools/tool';
|
import type { ModalState, Tool, ToolActionResult } from './tools/tool';
|
||||||
|
import type { Config } from '../config';
|
||||||
export type ContextOptions = {
|
|
||||||
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 +30,18 @@ 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;
|
||||||
|
|
||||||
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 +58,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 +118,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;
|
||||||
|
|
||||||
@@ -259,6 +253,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 +275,42 @@ ${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)
|
return this._createBrowserContextPromise;
|
||||||
url.searchParams.set('browser', this.options.browserName);
|
}
|
||||||
if (this.options.launchOptions)
|
|
||||||
url.searchParams.set('launch-options', JSON.stringify(this.options.launchOptions));
|
private async _innerCreateBrowserContext(): Promise<{ browser?: playwright.Browser, browserContext: playwright.BrowserContext }> {
|
||||||
const browser = await playwright[this.options.browserName ?? 'chromium'].connect(String(url));
|
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}`);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
88
src/index.ts
88
src/index.ts
@@ -14,10 +14,6 @@
|
|||||||
* limitations under the License.
|
* limitations under the License.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import path from 'path';
|
|
||||||
import os from 'os';
|
|
||||||
import fs from 'fs';
|
|
||||||
|
|
||||||
import { createServerWithTools } from './server';
|
import { createServerWithTools } from './server';
|
||||||
import common from './tools/common';
|
import common from './tools/common';
|
||||||
import console from './tools/console';
|
import console from './tools/console';
|
||||||
@@ -26,16 +22,17 @@ import files from './tools/files';
|
|||||||
import install from './tools/install';
|
import install from './tools/install';
|
||||||
import keyboard from './tools/keyboard';
|
import keyboard from './tools/keyboard';
|
||||||
import navigate from './tools/navigate';
|
import navigate from './tools/navigate';
|
||||||
|
import network from './tools/network';
|
||||||
import pdf from './tools/pdf';
|
import pdf from './tools/pdf';
|
||||||
import snapshot from './tools/snapshot';
|
import snapshot from './tools/snapshot';
|
||||||
import tabs from './tools/tabs';
|
import tabs from './tools/tabs';
|
||||||
import screen from './tools/screen';
|
import screen from './tools/screen';
|
||||||
|
|
||||||
import type { Tool, ToolCapability } from './tools/tool';
|
import type { Tool } from './tools/tool';
|
||||||
|
import type { Config } from '../config';
|
||||||
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,13 @@ const snapshotTools: Tool[] = [
|
|||||||
...install,
|
...install,
|
||||||
...keyboard(true),
|
...keyboard(true),
|
||||||
...navigate(true),
|
...navigate(true),
|
||||||
|
...network,
|
||||||
...pdf,
|
...pdf,
|
||||||
...snapshot,
|
...snapshot,
|
||||||
...tabs(true),
|
...tabs(true),
|
||||||
];
|
];
|
||||||
|
|
||||||
const screenshotTools: Tool[] = [
|
const screenshotTools: Tool<any>[] = [
|
||||||
...common(false),
|
...common(false),
|
||||||
...console,
|
...console,
|
||||||
...dialogs(false),
|
...dialogs(false),
|
||||||
@@ -56,84 +54,20 @@ const screenshotTools: Tool[] = [
|
|||||||
...install,
|
...install,
|
||||||
...keyboard(false),
|
...keyboard(false),
|
||||||
...navigate(false),
|
...navigate(false),
|
||||||
|
...network,
|
||||||
...pdf,
|
...pdf,
|
||||||
...screen,
|
...screen,
|
||||||
...tabs(false),
|
...tabs(false),
|
||||||
];
|
];
|
||||||
|
|
||||||
type Options = {
|
|
||||||
browser?: string;
|
|
||||||
userDataDir?: string;
|
|
||||||
headless?: boolean;
|
|
||||||
executablePath?: string;
|
|
||||||
cdpEndpoint?: string;
|
|
||||||
vision?: boolean;
|
|
||||||
capabilities?: ToolCapability[];
|
|
||||||
};
|
|
||||||
|
|
||||||
const packageJSON = require('../package.json');
|
const packageJSON = require('../package.json');
|
||||||
|
|
||||||
export async function createServer(options?: Options): Promise<Server> {
|
export async function createServer(config: Config = {}): Promise<Server> {
|
||||||
let browserName: 'chromium' | 'firefox' | 'webkit';
|
const allTools = config.vision ? screenshotTools : snapshotTools;
|
||||||
let channel: string | undefined;
|
const tools = allTools.filter(tool => !config.capabilities || tool.capability === 'core' || config.capabilities.includes(tool.capability));
|
||||||
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}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -14,18 +14,14 @@
|
|||||||
* 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';
|
import { createServer } from './index';
|
||||||
import { ServerList } from './server';
|
import { ServerList } from './server';
|
||||||
|
|
||||||
import assert from 'assert';
|
import { startHttpTransport, startStdioTransport } from './transport';
|
||||||
import { ToolCapability } from './tools/tool';
|
|
||||||
|
import { resolveConfig } from './config';
|
||||||
|
|
||||||
const packageJSON = require('../package.json');
|
const packageJSON = require('../package.json');
|
||||||
|
|
||||||
@@ -37,27 +33,22 @@ 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,
|
console.error(config);
|
||||||
userDataDir: options.userDataDir,
|
const serverList = new ServerList(() => createServer(config));
|
||||||
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 +64,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));
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|
||||||
import type { Tool } from './tools/tool';
|
import type { Tool } from './tools/tool';
|
||||||
import type { Resource } from './resources/resource';
|
import type { Config } from '../config';
|
||||||
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 () => {
|
||||||
|
|||||||
92
src/tab.ts
Normal file
92
src/tab.ts
Normal file
@@ -0,0 +1,92 @@
|
|||||||
|
/**
|
||||||
|
* Copyright (c) Microsoft Corporation.
|
||||||
|
*
|
||||||
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
* you may not use this file except in compliance with the License.
|
||||||
|
* You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing, software
|
||||||
|
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
* See the License for the specific language governing permissions and
|
||||||
|
* limitations under the License.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import * as playwright from 'playwright';
|
||||||
|
|
||||||
|
import { PageSnapshot } from './pageSnapshot';
|
||||||
|
|
||||||
|
import type { Context } from './context';
|
||||||
|
|
||||||
|
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.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';
|
||||||
|
|
||||||
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';
|
||||||
|
|
||||||
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';
|
||||||
|
|
||||||
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';
|
||||||
|
|
||||||
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';
|
||||||
|
|
||||||
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';
|
||||||
|
|
||||||
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';
|
||||||
|
|
||||||
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';
|
||||||
|
|
||||||
|
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';
|
||||||
|
|
||||||
import { sanitizeForFilePath } from './utils';
|
|
||||||
import * as javascript from '../javascript';
|
import * as javascript from '../javascript';
|
||||||
|
import { outputFile } from '../config';
|
||||||
|
|
||||||
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';
|
||||||
|
|
||||||
import * as javascript from '../javascript';
|
import * as javascript from '../javascript';
|
||||||
|
|
||||||
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';
|
||||||
import { generateLocator } from '../context';
|
|
||||||
import * as javascript from '../javascript';
|
import * as javascript from '../javascript';
|
||||||
|
import { outputFile } from '../config';
|
||||||
|
|
||||||
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';
|
||||||
|
|
||||||
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,
|
||||||
|
|||||||
@@ -15,17 +15,19 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import type { ImageContent, TextContent } from '@modelcontextprotocol/sdk/types';
|
import type { ImageContent, TextContent } from '@modelcontextprotocol/sdk/types';
|
||||||
import type { JsonSchema7Type } from 'zod-to-json-schema';
|
import type { z } from 'zod';
|
||||||
import type { Context } from '../context';
|
import type { Context } from '../context';
|
||||||
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';
|
||||||
|
|
||||||
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;
|
||||||
|
}
|
||||||
|
|||||||
127
src/transport.ts
Normal file
127
src/transport.ts
Normal file
@@ -0,0 +1,127 @@
|
|||||||
|
/**
|
||||||
|
* 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';
|
||||||
|
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 => 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}`;
|
||||||
|
}
|
||||||
|
console.log(`Listening on ${url}`);
|
||||||
|
console.log('Put this in your client config:');
|
||||||
|
console.log(JSON.stringify({
|
||||||
|
'mcpServers': {
|
||||||
|
'playwright': {
|
||||||
|
'url': `${url}/sse`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, undefined, 2));
|
||||||
|
console.log('If your client supports streamable HTTP, you can use the /mcp endpoint instead.');
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -32,6 +32,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',
|
||||||
@@ -56,6 +57,7 @@ test('test vision tool list', async ({ visionClient }) => {
|
|||||||
'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 +74,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"'],
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|
||||||
|
test('browser_take_screenshot (viewport)', 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`);
|
||||||
|
});
|
||||||
@@ -23,7 +23,23 @@ 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
|
||||||
|
- generic [ref=s1e2]:
|
||||||
|
- 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,12 @@ 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
|
||||||
|
- generic [ref=s3e2]:
|
||||||
|
- button "Choose File" [ref=s3e3]
|
||||||
|
- button "Button" [ref=s3e4]
|
||||||
|
\`\`\``);
|
||||||
}
|
}
|
||||||
|
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -14,6 +14,7 @@
|
|||||||
* limitations under the License.
|
* limitations under the License.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import fs from 'fs';
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
import { chromium } from 'playwright';
|
import { chromium } from 'playwright';
|
||||||
|
|
||||||
@@ -21,18 +22,24 @@ 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 { spawn } from 'child_process';
|
||||||
|
import { TestServer } from './testserver';
|
||||||
|
|
||||||
|
import type { Config } from '../config';
|
||||||
|
|
||||||
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: string;
|
||||||
|
server: TestServer;
|
||||||
|
httpsServer: TestServer;
|
||||||
|
mcpHeadless: boolean;
|
||||||
|
mcpBrowser: string | undefined;
|
||||||
};
|
};
|
||||||
|
|
||||||
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, WorkerFixtures>({
|
||||||
@@ -47,9 +54,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 +64,16 @@ 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}`);
|
||||||
|
}
|
||||||
const transport = new StdioClientTransport({
|
const transport = new StdioClientTransport({
|
||||||
command: 'node',
|
command: 'node',
|
||||||
args: [path.join(__dirname, '../cli.js'), ...args],
|
args: [path.join(__dirname, '../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;
|
||||||
@@ -85,6 +97,7 @@ export const test = baseTest.extend<TestFixtures, WorkerFixtures>({
|
|||||||
`--no-first-run`,
|
`--no-first-run`,
|
||||||
`--no-sandbox`,
|
`--no-sandbox`,
|
||||||
`--headless`,
|
`--headless`,
|
||||||
|
'--use-mock-keychain',
|
||||||
`data:text/html,hello world`,
|
`data:text/html,hello world`,
|
||||||
], {
|
], {
|
||||||
stdio: 'pipe',
|
stdio: 'pipe',
|
||||||
@@ -99,11 +112,36 @@ export const test = baseTest.extend<TestFixtures, WorkerFixtures>({
|
|||||||
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';
|
||||||
|
|
||||||
|
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`);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -24,12 +24,14 @@ test('stitched aria frames', async ({ client }) => {
|
|||||||
},
|
},
|
||||||
})).toContainTextContent(`
|
})).toContainTextContent(`
|
||||||
\`\`\`yaml
|
\`\`\`yaml
|
||||||
- heading "Hello" [level=1] [ref=s1e3]
|
- generic [ref=s1e2]:
|
||||||
- iframe [ref=s1e4]:
|
- heading "Hello" [level=1] [ref=s1e3]
|
||||||
- button "World" [ref=f1s1e3]
|
- iframe [ref=s1e4]:
|
||||||
- main [ref=f1s1e4]:
|
- generic [ref=f1s1e2]:
|
||||||
- iframe [ref=f1s1e5]:
|
- button "World" [ref=f1s1e3]
|
||||||
- paragraph [ref=f2s1e3]: Nested
|
- main [ref=f1s1e4]:
|
||||||
|
- iframe [ref=f1s1e5]:
|
||||||
|
- paragraph [ref=f2s1e3]: Nested
|
||||||
\`\`\``);
|
\`\`\``);
|
||||||
|
|
||||||
expect(await client.callTool({
|
expect(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({
|
||||||
|
|||||||
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';
|
||||||
|
|
||||||
|
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`);
|
||||||
|
});
|
||||||
@@ -41,6 +41,7 @@ test('save as pdf', async ({ client, mcpBrowser }) => {
|
|||||||
|
|
||||||
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,6 +14,8 @@
|
|||||||
* limitations under the License.
|
* limitations under the License.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import fs from 'fs';
|
||||||
|
|
||||||
import { test, expect } from './fixtures';
|
import { test, expect } from './fixtures';
|
||||||
|
|
||||||
test('browser_take_screenshot (viewport)', async ({ client }) => {
|
test('browser_take_screenshot (viewport)', async ({ client }) => {
|
||||||
@@ -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',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -16,27 +16,45 @@
|
|||||||
|
|
||||||
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';
|
||||||
|
import { expect } from 'playwright/test';
|
||||||
|
|
||||||
test('sse transport', async () => {
|
const test = baseTest.extend<{ serverEndpoint: string }>({
|
||||||
const cp = spawn('node', [path.join(__dirname, '../cli.js'), '--port', '0'], { stdio: 'pipe' });
|
serverEndpoint: async ({}, use) => {
|
||||||
try {
|
const cp = spawn('node', [path.join(__dirname, '../cli.js'), '--port', '0'], { stdio: 'pipe' });
|
||||||
let stdout = '';
|
try {
|
||||||
const url = await new Promise<string>(resolve => cp.stdout?.on('data', data => {
|
let stdout = '';
|
||||||
stdout += data.toString();
|
const url = await new Promise<string>(resolve => cp.stdout?.on('data', data => {
|
||||||
const match = stdout.match(/Listening on (http:\/\/.*)/);
|
stdout += data.toString();
|
||||||
if (match)
|
const match = stdout.match(/Listening on (http:\/\/.*)/);
|
||||||
resolve(match[1]);
|
if (match)
|
||||||
}));
|
resolve(match[1]);
|
||||||
|
}));
|
||||||
|
|
||||||
// need dynamic import b/c of some ESM nonsense
|
await use(url);
|
||||||
const { SSEClientTransport } = await import('@modelcontextprotocol/sdk/client/sse.js');
|
} finally {
|
||||||
const { Client } = await import('@modelcontextprotocol/sdk/client/index.js');
|
cp.kill();
|
||||||
const transport = new SSEClientTransport(new URL(url));
|
}
|
||||||
const client = new Client({ name: 'test', version: '1.0.0' });
|
},
|
||||||
await client.connect(transport);
|
});
|
||||||
await client.ping();
|
|
||||||
} finally {
|
test('sse transport', async ({ serverEndpoint }) => {
|
||||||
cp.kill();
|
// 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();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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>)`);
|
||||||
|
|||||||
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-----
|
||||||
145
tests/testserver/index.ts
Normal file
145
tests/testserver/index.ts
Normal file
@@ -0,0 +1,145 @@
|
|||||||
|
/**
|
||||||
|
* 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 http from 'http';
|
||||||
|
import https from 'https';
|
||||||
|
import path from 'path';
|
||||||
|
|
||||||
|
const fulfillSymbol = Symbol('fulfil callback');
|
||||||
|
const rejectSymbol = Symbol('reject callback');
|
||||||
|
|
||||||
|
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(__dirname, 'key.pem')),
|
||||||
|
cert: await fs.promises.readFile(path.join(__dirname, '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 = require('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';
|
||||||
|
|
||||||
|
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"],
|
||||||
|
}
|
||||||
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);
|
||||||
@@ -18,6 +18,7 @@
|
|||||||
|
|
||||||
const fs = require('node:fs');
|
const fs = require('node:fs');
|
||||||
const path = require('node:path');
|
const path = require('node:path');
|
||||||
|
const zodToJsonSchema = require('zod-to-json-schema').default;
|
||||||
|
|
||||||
const commonTools = require('../lib/tools/common').default;
|
const commonTools = require('../lib/tools/common').default;
|
||||||
const consoleTools = require('../lib/tools/console').default;
|
const consoleTools = require('../lib/tools/console').default;
|
||||||
@@ -107,11 +108,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`);
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user