Compare commits
25 Commits
v0.0.36
...
vscode-cli
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
76ba7f7bb6 | ||
|
|
dc149c19c0 | ||
|
|
74e3ab5267 | ||
|
|
d12b5aab18 | ||
|
|
922002e435 | ||
|
|
21e03968c5 | ||
|
|
ee59735f42 | ||
|
|
da5b0c6fdd | ||
|
|
35c464ef5b | ||
|
|
fcd953c097 | ||
|
|
14b931d25d | ||
|
|
bcbc2fecb8 | ||
|
|
5a0cfb9e65 | ||
|
|
1ff80f8761 | ||
|
|
98fef06b3b | ||
|
|
affe1d7ed9 | ||
|
|
cc61b67c14 | ||
|
|
7a814d5cd4 | ||
|
|
39c384850f | ||
|
|
f8a61de332 | ||
|
|
9d17572403 | ||
|
|
0741b8bee8 | ||
|
|
0d0783be07 | ||
|
|
001fa6f2fb | ||
|
|
e884b3aacb |
48
.github/workflows/publish-canary.yml
vendored
48
.github/workflows/publish-canary.yml
vendored
@@ -1,48 +0,0 @@
|
||||
name: Publish Canary
|
||||
on:
|
||||
schedule:
|
||||
- cron: '0 8 * * *'
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
publish-canary:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
id-token: write # Needed for npm provenance
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 18
|
||||
registry-url: https://registry.npmjs.org/
|
||||
|
||||
- name: Get current date
|
||||
id: date
|
||||
run: echo "date=$(date +'%Y-%m-%d')" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Get current version
|
||||
id: version
|
||||
run: echo "version=$(node -p "require('./package.json').version")" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Set canary version
|
||||
id: canary-version
|
||||
run: echo "version=${{ steps.version.outputs.version }}-alpha-${{ steps.date.outputs.date }}" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Update package.json version
|
||||
run: |
|
||||
npm version ${{ steps.canary-version.outputs.version }} --no-git-tag-version
|
||||
|
||||
- run: npm ci
|
||||
- run: npx playwright install --with-deps
|
||||
- run: npm run build
|
||||
- run: npm run lint
|
||||
- run: npm run ctest
|
||||
|
||||
- name: Publish to npm with next tag
|
||||
run: npm publish --tag next --provenance
|
||||
env:
|
||||
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
|
||||
|
||||
- name: Reset package.json version
|
||||
run: git checkout -- package.json
|
||||
116
README.md
116
README.md
@@ -56,21 +56,6 @@ Follow the MCP install [guide](https://modelcontextprotocol.io/quickstart/user),
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>Codex</summary>
|
||||
|
||||
Create or edit the configuration file `~/.codex/config.toml` and add:
|
||||
|
||||
```toml
|
||||
[mcp_servers.playwright]
|
||||
command = "npx"
|
||||
args = ["@playwright/mcp@latest"]
|
||||
```
|
||||
|
||||
For more information, see the [Codex MCP documentation](https://github.com/openai/codex/blob/main/codex-rs/config.md#mcp_servers).
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>Cursor</summary>
|
||||
|
||||
@@ -80,7 +65,7 @@ For more information, see the [Codex MCP documentation](https://github.com/opena
|
||||
|
||||
#### Or install manually:
|
||||
|
||||
Go to `Cursor Settings` -> `MCP` -> `Add new MCP Server`. Name to your liking, use `command` type with the command `npx @playwright/mcp@latest`. You can also verify config or add command like arguments via clicking `Edit`.
|
||||
Go to `Cursor Settings` -> `MCP` -> `Add new MCP Server`. Name to your liking, use `command` type with the command `npx @playwright/mcp`. You can also verify config or add command like arguments via clicking `Edit`.
|
||||
|
||||
</details>
|
||||
|
||||
@@ -494,15 +479,6 @@ http.createServer(async (req, res) => {
|
||||
|
||||
<!-- NOTE: This has been generated via update-readme.js -->
|
||||
|
||||
- **browser_fill_form**
|
||||
- Title: Fill form
|
||||
- Description: Fill multiple form fields
|
||||
- Parameters:
|
||||
- `fields` (array): Fields to fill in
|
||||
- Read-only: **false**
|
||||
|
||||
<!-- NOTE: This has been generated via update-readme.js -->
|
||||
|
||||
- **browser_handle_dialog**
|
||||
- Title: Handle a dialog
|
||||
- Description: Handle a dialog
|
||||
@@ -540,6 +516,14 @@ http.createServer(async (req, res) => {
|
||||
|
||||
<!-- NOTE: This has been generated via update-readme.js -->
|
||||
|
||||
- **browser_navigate_forward**
|
||||
- Title: Go forward
|
||||
- Description: Go forward to the next page
|
||||
- Parameters: None
|
||||
- Read-only: **true**
|
||||
|
||||
<!-- NOTE: This has been generated via update-readme.js -->
|
||||
|
||||
- **browser_network_requests**
|
||||
- Title: List network requests
|
||||
- Description: Returns all network requests since loading the page
|
||||
@@ -628,14 +612,39 @@ http.createServer(async (req, res) => {
|
||||
|
||||
<!-- NOTE: This has been generated via update-readme.js -->
|
||||
|
||||
- **browser_tabs**
|
||||
- Title: Manage tabs
|
||||
- Description: List, create, close, or select a browser tab.
|
||||
- **browser_tab_close**
|
||||
- Title: Close a tab
|
||||
- Description: Close a tab
|
||||
- Parameters:
|
||||
- `action` (string): Operation to perform
|
||||
- `index` (number, optional): Tab index, used for close/select. If omitted for close, current tab is closed.
|
||||
- `index` (number, optional): The index of the tab to close. Closes current tab if not provided.
|
||||
- Read-only: **false**
|
||||
|
||||
<!-- NOTE: This has been generated via update-readme.js -->
|
||||
|
||||
- **browser_tab_list**
|
||||
- Title: List tabs
|
||||
- Description: List browser tabs
|
||||
- Parameters: None
|
||||
- Read-only: **true**
|
||||
|
||||
<!-- NOTE: This has been generated via update-readme.js -->
|
||||
|
||||
- **browser_tab_new**
|
||||
- Title: Open a new tab
|
||||
- Description: Open a new tab
|
||||
- Parameters:
|
||||
- `url` (string, optional): The URL to navigate to in the new tab. If not provided, the new tab will be blank.
|
||||
- Read-only: **true**
|
||||
|
||||
<!-- NOTE: This has been generated via update-readme.js -->
|
||||
|
||||
- **browser_tab_select**
|
||||
- Title: Select a tab
|
||||
- Description: Select a tab by index
|
||||
- Parameters:
|
||||
- `index` (number): The index of the tab to select
|
||||
- Read-only: **true**
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
@@ -705,52 +714,5 @@ http.createServer(async (req, res) => {
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><b>Verify (opt-in via --caps=verify)</b></summary>
|
||||
|
||||
<!-- NOTE: This has been generated via update-readme.js -->
|
||||
|
||||
- **browser_verify_element_visible**
|
||||
- Title: Verify element visible
|
||||
- Description: Verify element is visible on the page
|
||||
- Parameters:
|
||||
- `role` (string): ROLE of the element. Can be found in the snapshot like this: `- {ROLE} "Accessible Name":`
|
||||
- `accessibleName` (string): ACCESSIBLE_NAME of the element. Can be found in the snapshot like this: `- role "{ACCESSIBLE_NAME}"`
|
||||
- Read-only: **true**
|
||||
|
||||
<!-- NOTE: This has been generated via update-readme.js -->
|
||||
|
||||
- **browser_verify_list_visible**
|
||||
- Title: Verify list visible
|
||||
- Description: Verify list is visible on the page
|
||||
- Parameters:
|
||||
- `element` (string): Human-readable list description
|
||||
- `ref` (string): Exact target element reference that points to the list
|
||||
- `items` (array): Items to verify
|
||||
- Read-only: **true**
|
||||
|
||||
<!-- NOTE: This has been generated via update-readme.js -->
|
||||
|
||||
- **browser_verify_text_visible**
|
||||
- Title: Verify text visible
|
||||
- Description: Verify text is visible on the page. Prefer browser_verify_element_visible if possible.
|
||||
- Parameters:
|
||||
- `text` (string): TEXT to verify. Can be found in the snapshot like this: `- role "Accessible Name": {TEXT}` or like this: `- text: {TEXT}`
|
||||
- Read-only: **true**
|
||||
|
||||
<!-- NOTE: This has been generated via update-readme.js -->
|
||||
|
||||
- **browser_verify_value**
|
||||
- Title: Verify value
|
||||
- Description: Verify element value
|
||||
- Parameters:
|
||||
- `type` (string): Type of the element
|
||||
- `element` (string): Human-readable element description
|
||||
- `ref` (string): Exact target element reference that points to the element
|
||||
- `value` (string): Value to verify. For checkbox, use "true" or "false".
|
||||
- Read-only: **true**
|
||||
|
||||
</details>
|
||||
|
||||
|
||||
<!--- End of tools generated section -->
|
||||
|
||||
2
config.d.ts
vendored
2
config.d.ts
vendored
@@ -16,7 +16,7 @@
|
||||
|
||||
import type * as playwright from 'playwright';
|
||||
|
||||
export type ToolCapability = 'core' | 'core-tabs' | 'core-install' | 'vision' | 'pdf' | 'verify';
|
||||
export type ToolCapability = 'core' | 'core-tabs' | 'core-install' | 'vision' | 'pdf';
|
||||
|
||||
export type Config = {
|
||||
/**
|
||||
|
||||
@@ -1,22 +1,26 @@
|
||||
{
|
||||
"manifest_version": 3,
|
||||
"name": "Playwright MCP Bridge",
|
||||
"version": "0.0.36",
|
||||
"version": "0.0.34",
|
||||
"description": "Share browser tabs with Playwright MCP server",
|
||||
"key": "MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA9nMS2b0WCohjVHPGb8D9qAdkbIngDqoAjTeSccHJijgcONejge+OJxOQOMLu7b0ovt1c9BiEJa5JcpM+EHFVGL1vluBxK71zmBy1m2f9vZF3HG0LSCp7YRkum9rAIEthDwbkxx6XTvpmAY5rjFa/NON6b9Hlbo+8peUSkoOK7HTwYnnI36asZ9eUTiveIf+DMPLojW2UX33vDWG2UKvMVDewzclb4+uLxAYshY7Mx8we/b44xu+Anb/EBLKjOPk9Yh541xJ5Ozc8EiP/5yxOp9c/lRiYUHaRW+4r0HKZyFt0eZ52ti2iM4Nfk7jRXR7an3JPsUIf5deC/1cVM/+1ZQIDAQAB",
|
||||
|
||||
"permissions": [
|
||||
"debugger",
|
||||
"activeTab",
|
||||
"tabs",
|
||||
"storage"
|
||||
],
|
||||
|
||||
"host_permissions": [
|
||||
"<all_urls>"
|
||||
],
|
||||
|
||||
"background": {
|
||||
"service_worker": "lib/background.js",
|
||||
"type": "module"
|
||||
},
|
||||
|
||||
"action": {
|
||||
"default_title": "Playwright MCP Bridge",
|
||||
"default_icon": {
|
||||
@@ -26,6 +30,7 @@
|
||||
"128": "icons/icon-128.png"
|
||||
}
|
||||
},
|
||||
|
||||
"icons": {
|
||||
"16": "icons/icon-16.png",
|
||||
"32": "icons/icon-32.png",
|
||||
|
||||
4
extension/package-lock.json
generated
4
extension/package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "@playwright/mcp-extension",
|
||||
"version": "0.0.36",
|
||||
"version": "0.0.34",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@playwright/mcp-extension",
|
||||
"version": "0.0.36",
|
||||
"version": "0.0.34",
|
||||
"license": "Apache-2.0",
|
||||
"devDependencies": {
|
||||
"@types/chrome": "^0.0.315",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@playwright/mcp-extension",
|
||||
"version": "0.0.36",
|
||||
"version": "0.0.34",
|
||||
"description": "Playwright MCP Browser Extension",
|
||||
"type": "module",
|
||||
"private": true,
|
||||
|
||||
@@ -23,8 +23,8 @@ type PageMessage = {
|
||||
type: 'getTabs';
|
||||
} | {
|
||||
type: 'connectToTab';
|
||||
tabId?: number;
|
||||
windowId?: number;
|
||||
tabId: number;
|
||||
windowId: number;
|
||||
mcpRelayUrl: string;
|
||||
} | {
|
||||
type: 'getConnectionStatus';
|
||||
@@ -59,9 +59,7 @@ class TabShareExtension {
|
||||
(error: any) => sendResponse({ success: false, error: error.message }));
|
||||
return true;
|
||||
case 'connectToTab':
|
||||
const tabId = message.tabId || sender.tab?.id!;
|
||||
const windowId = message.windowId || sender.tab?.windowId!;
|
||||
this._connectTab(sender.tab!.id!, tabId, windowId, message.mcpRelayUrl!).then(
|
||||
this._connectTab(sender.tab!.id!, message.tabId, message.windowId, message.mcpRelayUrl!).then(
|
||||
() => sendResponse({ success: true }),
|
||||
(error: any) => sendResponse({ success: false, error: error.message }));
|
||||
return true; // Return true to indicate that the response will be sent asynchronously
|
||||
|
||||
@@ -192,15 +192,4 @@ body {
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
/* Link-style button */
|
||||
.link-button {
|
||||
background: none;
|
||||
border: none;
|
||||
color: #0066cc;
|
||||
text-decoration: underline;
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
font: inherit;
|
||||
}
|
||||
@@ -23,9 +23,7 @@ type Status =
|
||||
| { type: 'connecting'; message: string }
|
||||
| { type: 'connected'; message: string }
|
||||
| { type: 'error'; message: string }
|
||||
| { type: 'error'; versionMismatch: { extensionVersion: string; } };
|
||||
|
||||
const SUPPORTED_PROTOCOL_VERSION = 1;
|
||||
| { type: 'error'; versionMismatch: { pwMcpVersion: string; extensionVersion: string } };
|
||||
|
||||
const ConnectApp: React.FC = () => {
|
||||
const [tabs, setTabs] = useState<TabInfo[]>([]);
|
||||
@@ -34,7 +32,6 @@ const ConnectApp: React.FC = () => {
|
||||
const [showTabList, setShowTabList] = useState(true);
|
||||
const [clientInfo, setClientInfo] = useState('unknown');
|
||||
const [mcpRelayUrl, setMcpRelayUrl] = useState('');
|
||||
const [newTab, setNewTab] = useState<boolean>(false);
|
||||
|
||||
useEffect(() => {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
@@ -61,30 +58,23 @@ const ConnectApp: React.FC = () => {
|
||||
return;
|
||||
}
|
||||
|
||||
const parsedVersion = parseInt(params.get('protocolVersion') ?? '', 10);
|
||||
const requiredVersion = isNaN(parsedVersion) ? 1 : parsedVersion;
|
||||
if (requiredVersion > SUPPORTED_PROTOCOL_VERSION) {
|
||||
const extensionVersion = chrome.runtime.getManifest().version;
|
||||
const pwMcpVersion = params.get('pwMcpVersion');
|
||||
const extensionVersion = chrome.runtime.getManifest().version;
|
||||
if (pwMcpVersion !== extensionVersion) {
|
||||
setShowButtons(false);
|
||||
setShowTabList(false);
|
||||
setStatus({
|
||||
type: 'error',
|
||||
versionMismatch: {
|
||||
extensionVersion,
|
||||
pwMcpVersion: pwMcpVersion || 'unknown',
|
||||
extensionVersion
|
||||
}
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
void connectToMCPRelay(relayUrl);
|
||||
|
||||
// If this is a browser_navigate command, hide the tab list and show simple allow/reject
|
||||
if (params.get('newTab') === 'true') {
|
||||
setNewTab(true);
|
||||
setShowTabList(false);
|
||||
} else {
|
||||
void loadTabs();
|
||||
}
|
||||
void loadTabs();
|
||||
}, []);
|
||||
|
||||
const handleReject = useCallback((message: string) => {
|
||||
@@ -108,7 +98,7 @@ const ConnectApp: React.FC = () => {
|
||||
setStatus({ type: 'error', message: 'Failed to load tabs: ' + response.error });
|
||||
}, []);
|
||||
|
||||
const handleConnectToTab = useCallback(async (tab?: TabInfo) => {
|
||||
const handleConnectToTab = useCallback(async (tab: TabInfo) => {
|
||||
setShowButtons(false);
|
||||
setShowTabList(false);
|
||||
|
||||
@@ -116,8 +106,8 @@ const ConnectApp: React.FC = () => {
|
||||
const response = await chrome.runtime.sendMessage({
|
||||
type: 'connectToTab',
|
||||
mcpRelayUrl,
|
||||
tabId: tab?.id,
|
||||
windowId: tab?.windowId,
|
||||
tabId: tab.id,
|
||||
windowId: tab.windowId,
|
||||
});
|
||||
|
||||
if (response?.success) {
|
||||
@@ -154,22 +144,9 @@ const ConnectApp: React.FC = () => {
|
||||
<div className='status-container'>
|
||||
<StatusBanner status={status} />
|
||||
{showButtons && (
|
||||
<div className='button-container'>
|
||||
{newTab ? (
|
||||
<>
|
||||
<Button variant='primary' onClick={() => handleConnectToTab()}>
|
||||
Allow
|
||||
</Button>
|
||||
<Button variant='reject' onClick={() => handleReject('Connection rejected. This tab can be closed.')}>
|
||||
Reject
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<Button variant='reject' onClick={() => handleReject('Connection rejected. This tab can be closed.')}>
|
||||
Reject
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<Button variant='reject' onClick={() => handleReject('Connection rejected. This tab can be closed.')}>
|
||||
Reject
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
@@ -199,14 +176,13 @@ const ConnectApp: React.FC = () => {
|
||||
);
|
||||
};
|
||||
|
||||
const VersionMismatchError: React.FC<{ extensionVersion: string }> = ({ extensionVersion }) => {
|
||||
const VersionMismatchError: React.FC<{ pwMcpVersion: string; extensionVersion: string }> = ({ pwMcpVersion, extensionVersion }) => {
|
||||
const readmeUrl = 'https://github.com/microsoft/playwright-mcp/blob/main/extension/README.md';
|
||||
const latestReleaseUrl = 'https://github.com/microsoft/playwright-mcp/releases/latest';
|
||||
return (
|
||||
<div>
|
||||
Playwright MCP version trying to connect requires newer extension version (current version: {extensionVersion}).{' '}
|
||||
<a href={latestReleaseUrl}>Click here</a> to download latest version of the extension, then drag and drop it into the Chrome Extensions page.{' '}
|
||||
See <a href={readmeUrl} target='_blank' rel='noopener noreferrer'>installation instructions</a> for more details.
|
||||
Incompatible Playwright MCP version: {pwMcpVersion} (extension version: {extensionVersion}).
|
||||
Please install the latest version of the extension.{' '}
|
||||
See <a href={readmeUrl} target='_blank' rel='noopener noreferrer'>installation instructions</a>.
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -215,9 +191,7 @@ const StatusBanner: React.FC<{ status: Status }> = ({ status }) => {
|
||||
return (
|
||||
<div className={`status-banner ${status.type}`}>
|
||||
{'versionMismatch' in status ? (
|
||||
<VersionMismatchError
|
||||
extensionVersion={status.versionMismatch.extensionVersion}
|
||||
/>
|
||||
<VersionMismatchError pwMcpVersion={status.versionMismatch.pwMcpVersion} extensionVersion={status.versionMismatch.extensionVersion} />
|
||||
) : (
|
||||
status.message
|
||||
)}
|
||||
|
||||
@@ -18,6 +18,7 @@ import fs from 'fs';
|
||||
import path from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
import { chromium } from 'playwright';
|
||||
import packageJSON from '../../package.json' assert { type: 'json' };
|
||||
import { test as base, expect } from '../../tests/fixtures.js';
|
||||
|
||||
import type { Client } from '@modelcontextprotocol/sdk/client/index.js';
|
||||
@@ -33,7 +34,6 @@ type TestFixtures = {
|
||||
browserWithExtension: BrowserWithExtension,
|
||||
pathToExtension: string,
|
||||
useShortConnectionTimeout: (timeoutMs: number) => void
|
||||
overrideProtocolVersion: (version: number) => void
|
||||
};
|
||||
|
||||
const test = base.extend<TestFixtures>({
|
||||
@@ -81,12 +81,6 @@ const test = base.extend<TestFixtures>({
|
||||
process.env.PWMCP_TEST_CONNECTION_TIMEOUT = undefined;
|
||||
},
|
||||
|
||||
overrideProtocolVersion: async ({}, use) => {
|
||||
await use((version: number) => {
|
||||
process.env.PWMCP_TEST_PROTOCOL_VERSION = version.toString();
|
||||
});
|
||||
process.env.PWMCP_TEST_PROTOCOL_VERSION = undefined;
|
||||
}
|
||||
});
|
||||
|
||||
async function startAndCallConnectTool(browserWithExtension: BrowserWithExtension, startClient: StartClient): Promise<Client> {
|
||||
@@ -123,7 +117,7 @@ async function startWithExtensionFlag(browserWithExtension: BrowserWithExtension
|
||||
return client;
|
||||
}
|
||||
|
||||
const testWithOldExtensionVersion = test.extend({
|
||||
const testWithOldVersion = test.extend({
|
||||
pathToExtension: async ({}, use, testInfo) => {
|
||||
const extensionDir = testInfo.outputPath('extension');
|
||||
const oldPath = fileURLToPath(new URL('../dist', import.meta.url));
|
||||
@@ -158,8 +152,7 @@ for (const [mode, startClientMethod] of [
|
||||
});
|
||||
|
||||
const selectorPage = await confirmationPagePromise;
|
||||
// For browser_navigate command, the UI shows Allow/Reject buttons instead of tab selector
|
||||
await selectorPage.getByRole('button', { name: 'Allow' }).click();
|
||||
await selectorPage.locator('.tab-item', { hasText: 'Playwright MCP Extension' }).getByRole('button', { name: 'Connect' }).click();
|
||||
|
||||
expect(await navigateResponse).toHaveResponse({
|
||||
pageState: expect.stringContaining(`- generic [active] [ref=e1]: Hello, world!`),
|
||||
@@ -222,7 +215,7 @@ for (const [mode, startClientMethod] of [
|
||||
await confirmationPagePromise;
|
||||
});
|
||||
|
||||
testWithOldExtensionVersion(`works with old extension version (${mode})`, async ({ browserWithExtension, startClient, server, useShortConnectionTimeout }) => {
|
||||
testWithOldVersion(`extension version mismatch (${mode})`, async ({ browserWithExtension, startClient, server, useShortConnectionTimeout }) => {
|
||||
useShortConnectionTimeout(500);
|
||||
|
||||
// Prelaunch the browser, so that it is properly closed after the test.
|
||||
@@ -239,35 +232,8 @@ for (const [mode, startClientMethod] of [
|
||||
arguments: { url: server.HELLO_WORLD },
|
||||
});
|
||||
|
||||
const selectorPage = await confirmationPagePromise;
|
||||
// For browser_navigate command, the UI shows Allow/Reject buttons instead of tab selector
|
||||
await selectorPage.getByRole('button', { name: 'Allow' }).click();
|
||||
|
||||
expect(await navigateResponse).toHaveResponse({
|
||||
pageState: expect.stringContaining(`- generic [active] [ref=e1]: Hello, world!`),
|
||||
});
|
||||
});
|
||||
|
||||
test(`extension needs update (${mode})`, async ({ browserWithExtension, startClient, server, useShortConnectionTimeout, overrideProtocolVersion }) => {
|
||||
useShortConnectionTimeout(500);
|
||||
overrideProtocolVersion(1000);
|
||||
|
||||
// Prelaunch the browser, so that it is properly closed after the test.
|
||||
const browserContext = await browserWithExtension.launch();
|
||||
|
||||
const client = await startClientMethod(browserWithExtension, startClient);
|
||||
|
||||
const confirmationPagePromise = browserContext.waitForEvent('page', page => {
|
||||
return page.url().startsWith('chrome-extension://jakfalbnbhgkpmoaakfflhflbfpkailf/connect.html');
|
||||
});
|
||||
|
||||
const navigateResponse = client.callTool({
|
||||
name: 'browser_navigate',
|
||||
arguments: { url: server.HELLO_WORLD },
|
||||
});
|
||||
|
||||
const confirmationPage = await confirmationPagePromise;
|
||||
await expect(confirmationPage.locator('.status-banner')).toContainText(`Playwright MCP version trying to connect requires newer extension version`);
|
||||
await expect(confirmationPage.locator('.status-banner')).toHaveText(`Incompatible Playwright MCP version: ${packageJSON.version} (extension version: 0.0.1). Please install the latest version of the extension. See installation instructions.`);
|
||||
|
||||
expect(await navigateResponse).toHaveResponse({
|
||||
result: expect.stringContaining('Extension connection timeout.'),
|
||||
@@ -276,32 +242,3 @@ for (const [mode, startClientMethod] of [
|
||||
});
|
||||
|
||||
}
|
||||
|
||||
test(`custom executablePath`, async ({ startClient, server, useShortConnectionTimeout }) => {
|
||||
useShortConnectionTimeout(1000);
|
||||
|
||||
const executablePath = test.info().outputPath('echo.sh');
|
||||
await fs.promises.writeFile(executablePath, '#!/bin/bash\necho "Custom exec args: $@" > "$(dirname "$0")/output.txt"', { mode: 0o755 });
|
||||
|
||||
const { client } = await startClient({
|
||||
args: [`--extension`],
|
||||
config: {
|
||||
browser: {
|
||||
launchOptions: {
|
||||
executablePath,
|
||||
},
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
const navigateResponse = await client.callTool({
|
||||
name: 'browser_navigate',
|
||||
arguments: { url: server.HELLO_WORLD },
|
||||
timeout: 1000,
|
||||
});
|
||||
expect(await navigateResponse).toHaveResponse({
|
||||
result: expect.stringContaining('Extension connection timeout.'),
|
||||
isError: true,
|
||||
});
|
||||
expect(await fs.promises.readFile(test.info().outputPath('output.txt'), 'utf8')).toContain('Custom exec args: chrome-extension://jakfalbnbhgkpmoaakfflhflbfpkailf/connect.html?');
|
||||
});
|
||||
|
||||
32
package-lock.json
generated
32
package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "@playwright/mcp",
|
||||
"version": "0.0.36",
|
||||
"version": "0.0.34",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@playwright/mcp",
|
||||
"version": "0.0.36",
|
||||
"version": "0.0.34",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@modelcontextprotocol/sdk": "^1.16.0",
|
||||
@@ -14,8 +14,8 @@
|
||||
"debug": "^4.4.1",
|
||||
"dotenv": "^17.2.0",
|
||||
"mime": "^4.0.7",
|
||||
"playwright": "1.56.0-alpha-1756505518000",
|
||||
"playwright-core": "1.56.0-alpha-1756505518000",
|
||||
"playwright": "1.55.0-alpha-2025-08-12",
|
||||
"playwright-core": "1.55.0-alpha-2025-08-12",
|
||||
"ws": "^8.18.1",
|
||||
"zod": "^3.24.1",
|
||||
"zod-to-json-schema": "^3.24.4"
|
||||
@@ -27,7 +27,7 @@
|
||||
"@anthropic-ai/sdk": "^0.57.0",
|
||||
"@eslint/eslintrc": "^3.2.0",
|
||||
"@eslint/js": "^9.19.0",
|
||||
"@playwright/test": "1.56.0-alpha-1756505518000",
|
||||
"@playwright/test": "1.55.0-alpha-2025-08-12",
|
||||
"@stylistic/eslint-plugin": "^3.0.1",
|
||||
"@types/debug": "^4.1.12",
|
||||
"@types/node": "^22.13.10",
|
||||
@@ -703,13 +703,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@playwright/test": {
|
||||
"version": "1.56.0-alpha-1756505518000",
|
||||
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.56.0-alpha-1756505518000.tgz",
|
||||
"integrity": "sha512-BLTEYook8jXHONKqmOgcG/q6SLZIyyJClgc+YJGg/G3w3dg1pE2dtdO/gECFnM8FX9UY4DOa9c6eJVU1feHk/w==",
|
||||
"version": "1.55.0-alpha-2025-08-12",
|
||||
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.55.0-alpha-2025-08-12.tgz",
|
||||
"integrity": "sha512-lyq9MDSd4UcOWx5292AYLBfbYYCstg8iLb+lk6LdM69ps6bwmPloZO3Ol3JO3FQQ63qAuW9VD0w+ZYKL0lRmQA==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"playwright": "1.56.0-alpha-1756505518000"
|
||||
"playwright": "1.55.0-alpha-2025-08-12"
|
||||
},
|
||||
"bin": {
|
||||
"playwright": "cli.js"
|
||||
@@ -3745,12 +3745,12 @@
|
||||
}
|
||||
},
|
||||
"node_modules/playwright": {
|
||||
"version": "1.56.0-alpha-1756505518000",
|
||||
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.56.0-alpha-1756505518000.tgz",
|
||||
"integrity": "sha512-aChIG1Hly/pxzVdwOMArmOMNz4Wo2VyWBxLaMvLJaGWRPPB9+Sl1N8PRm6oH1CbbpFGpPvIeXl83LomkibShRA==",
|
||||
"version": "1.55.0-alpha-2025-08-12",
|
||||
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.55.0-alpha-2025-08-12.tgz",
|
||||
"integrity": "sha512-daZPM5gX0VTG6ae3/qOpEKc9NxoavkM2lfL0UIzTG0k+yK8ZeSPYo63iewZhVANsWRm0BT+XQ1NniAUOwWQ+xA==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"playwright-core": "1.56.0-alpha-1756505518000"
|
||||
"playwright-core": "1.55.0-alpha-2025-08-12"
|
||||
},
|
||||
"bin": {
|
||||
"playwright": "cli.js"
|
||||
@@ -3763,9 +3763,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/playwright-core": {
|
||||
"version": "1.56.0-alpha-1756505518000",
|
||||
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.56.0-alpha-1756505518000.tgz",
|
||||
"integrity": "sha512-qeM+G9jA+PkA3dSYZmqKrARnIgd53B+7Lm3e52wH3rPyZJ+IBhRvhW369iN8tVJunbmsr7fkU1+05K2c7q9y0g==",
|
||||
"version": "1.55.0-alpha-2025-08-12",
|
||||
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.55.0-alpha-2025-08-12.tgz",
|
||||
"integrity": "sha512-4uxOd9xmeF6gqdsORzzlXd7p795vcACOiAGVHHEiTuFXsD83LYH+0C/SYLWB0Z+fAq4LdKGsy0qEfTm0JkY8Ig==",
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
"playwright-core": "cli.js"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@playwright/mcp",
|
||||
"version": "0.0.36",
|
||||
"version": "0.0.34",
|
||||
"description": "Playwright Tools for MCP",
|
||||
"type": "module",
|
||||
"repository": {
|
||||
@@ -43,8 +43,8 @@
|
||||
"debug": "^4.4.1",
|
||||
"dotenv": "^17.2.0",
|
||||
"mime": "^4.0.7",
|
||||
"playwright": "1.56.0-alpha-1756505518000",
|
||||
"playwright-core": "1.56.0-alpha-1756505518000",
|
||||
"playwright": "1.55.0-alpha-2025-08-12",
|
||||
"playwright-core": "1.55.0-alpha-2025-08-12",
|
||||
"ws": "^8.18.1",
|
||||
"zod": "^3.24.1",
|
||||
"zod-to-json-schema": "^3.24.4"
|
||||
@@ -53,7 +53,7 @@
|
||||
"@anthropic-ai/sdk": "^0.57.0",
|
||||
"@eslint/eslintrc": "^3.2.0",
|
||||
"@eslint/js": "^9.19.0",
|
||||
"@playwright/test": "1.56.0-alpha-1756505518000",
|
||||
"@playwright/test": "1.55.0-alpha-2025-08-12",
|
||||
"@stylistic/eslint-plugin": "^3.0.1",
|
||||
"@types/debug": "^4.1.12",
|
||||
"@types/node": "^22.13.10",
|
||||
|
||||
@@ -42,7 +42,7 @@ export function contextFactory(config: FullConfig): BrowserContextFactory {
|
||||
export type ClientInfo = { name?: string, version?: string, rootPath?: string };
|
||||
|
||||
export interface BrowserContextFactory {
|
||||
createContext(clientInfo: ClientInfo, abortSignal: AbortSignal, toolName: string | undefined): Promise<{ browserContext: playwright.BrowserContext, close: () => Promise<void> }>;
|
||||
createContext(clientInfo: ClientInfo, abortSignal: AbortSignal): Promise<{ browserContext: playwright.BrowserContext, close: () => Promise<void> }>;
|
||||
}
|
||||
|
||||
class BaseContextFactory implements BrowserContextFactory {
|
||||
|
||||
@@ -41,7 +41,7 @@ export class BrowserServerBackend implements ServerBackend {
|
||||
this._tools = filteredTools(config);
|
||||
}
|
||||
|
||||
async initialize(server: mcpServer.Server, clientVersion: mcpServer.ClientVersion, roots: mcpServer.Root[]): Promise<void> {
|
||||
async initialize(clientVersion: mcpServer.ClientVersion, roots: mcpServer.Root[]): Promise<void> {
|
||||
let rootPath: string | undefined;
|
||||
if (roots.length > 0) {
|
||||
const firstRootUri = roots[0]?.uri;
|
||||
@@ -69,7 +69,7 @@ export class BrowserServerBackend implements ServerBackend {
|
||||
const parsedArguments = tool.schema.inputSchema.parse(rawArguments || {});
|
||||
const context = this._context!;
|
||||
const response = new Response(context, name, parsedArguments);
|
||||
context.setRunningTool(name);
|
||||
context.setRunningTool(true);
|
||||
try {
|
||||
await tool.handle(context, parsedArguments, response);
|
||||
await response.finish();
|
||||
@@ -77,7 +77,7 @@ export class BrowserServerBackend implements ServerBackend {
|
||||
} catch (error: any) {
|
||||
response.addError(String(error));
|
||||
} finally {
|
||||
context.setRunningTool(undefined);
|
||||
context.setRunningTool(false);
|
||||
}
|
||||
return response.serialize();
|
||||
}
|
||||
|
||||
@@ -50,7 +50,7 @@ export class Context {
|
||||
|
||||
private static _allContexts: Set<Context> = new Set();
|
||||
private _closeBrowserContextPromise: Promise<void> | undefined;
|
||||
private _runningToolName: string | undefined;
|
||||
private _isRunningTool: boolean = false;
|
||||
private _abortController = new AbortController();
|
||||
|
||||
constructor(options: ContextOptions) {
|
||||
@@ -145,11 +145,11 @@ export class Context {
|
||||
}
|
||||
|
||||
isRunningTool() {
|
||||
return this._runningToolName !== undefined;
|
||||
return this._isRunningTool;
|
||||
}
|
||||
|
||||
setRunningTool(name: string | undefined) {
|
||||
this._runningToolName = name;
|
||||
setRunningTool(isRunningTool: boolean) {
|
||||
this._isRunningTool = isRunningTool;
|
||||
}
|
||||
|
||||
private async _closeBrowserContextImpl() {
|
||||
@@ -202,7 +202,7 @@ export class Context {
|
||||
if (this._closeBrowserContextPromise)
|
||||
throw new Error('Another browser context is being closed.');
|
||||
// TODO: move to the browser context factory to make it based on isolation mode.
|
||||
const result = await this._browserContextFactory.createContext(this._clientInfo, this._abortController.signal, this._runningToolName);
|
||||
const result = await this._browserContextFactory.createContext(this._clientInfo, this._abortController.signal);
|
||||
const { browserContext } = result;
|
||||
await this._setupRequestInterception(browserContext);
|
||||
if (this.sessionLog)
|
||||
|
||||
@@ -28,12 +28,11 @@ import debug from 'debug';
|
||||
import { WebSocket, WebSocketServer } from 'ws';
|
||||
import { httpAddressToString } from '../mcp/http.js';
|
||||
import { logUnhandledError } from '../utils/log.js';
|
||||
import { ManualPromise } from '../mcp/manualPromise.js';
|
||||
import * as protocol from './protocol.js';
|
||||
import { ManualPromise } from '../utils/manualPromise.js';
|
||||
import { packageJSON } from '../utils/package.js';
|
||||
|
||||
import type websocket from 'ws';
|
||||
import type { ClientInfo } from '../browserContextFactory.js';
|
||||
import type { ExtensionCommand, ExtensionEvents } from './protocol.js';
|
||||
|
||||
// @ts-ignore
|
||||
const { registry } = await import('playwright-core/lib/server/registry/index');
|
||||
@@ -60,7 +59,6 @@ export class CDPRelayServer {
|
||||
private _wsHost: string;
|
||||
private _browserChannel: string;
|
||||
private _userDataDir?: string;
|
||||
private _executablePath?: string;
|
||||
private _cdpPath: string;
|
||||
private _extensionPath: string;
|
||||
private _wss: WebSocketServer;
|
||||
@@ -74,11 +72,10 @@ export class CDPRelayServer {
|
||||
private _nextSessionId: number = 1;
|
||||
private _extensionConnectionPromise!: ManualPromise<void>;
|
||||
|
||||
constructor(server: http.Server, browserChannel: string, userDataDir?: string, executablePath?: string) {
|
||||
constructor(server: http.Server, browserChannel: string, userDataDir?: string) {
|
||||
this._wsHost = httpAddressToString(server.address()).replace(/^http/, 'ws');
|
||||
this._browserChannel = browserChannel;
|
||||
this._userDataDir = userDataDir;
|
||||
this._executablePath = executablePath;
|
||||
|
||||
const uuid = crypto.randomUUID();
|
||||
this._cdpPath = `/cdp/${uuid}`;
|
||||
@@ -97,11 +94,11 @@ export class CDPRelayServer {
|
||||
return `${this._wsHost}${this._extensionPath}`;
|
||||
}
|
||||
|
||||
async ensureExtensionConnectionForMCPContext(clientInfo: ClientInfo, abortSignal: AbortSignal, toolName: string | undefined) {
|
||||
async ensureExtensionConnectionForMCPContext(clientInfo: ClientInfo, abortSignal: AbortSignal) {
|
||||
debugLogger('Ensuring extension connection for MCP context');
|
||||
if (this._extensionConnection)
|
||||
return;
|
||||
this._connectBrowser(clientInfo, toolName);
|
||||
this._connectBrowser(clientInfo);
|
||||
debugLogger('Waiting for incoming extension connection');
|
||||
await Promise.race([
|
||||
this._extensionConnectionPromise,
|
||||
@@ -113,7 +110,7 @@ export class CDPRelayServer {
|
||||
debugLogger('Extension connection established');
|
||||
}
|
||||
|
||||
private _connectBrowser(clientInfo: ClientInfo, toolName: string | undefined) {
|
||||
private _connectBrowser(clientInfo: ClientInfo) {
|
||||
const mcpRelayEndpoint = `${this._wsHost}${this._extensionPath}`;
|
||||
// Need to specify "key" in the manifest.json to make the id stable when loading from file.
|
||||
const url = new URL('chrome-extension://jakfalbnbhgkpmoaakfflhflbfpkailf/connect.html');
|
||||
@@ -123,20 +120,14 @@ export class CDPRelayServer {
|
||||
version: clientInfo.version,
|
||||
};
|
||||
url.searchParams.set('client', JSON.stringify(client));
|
||||
url.searchParams.set('protocolVersion', process.env.PWMCP_TEST_PROTOCOL_VERSION ?? protocol.VERSION.toString());
|
||||
if (toolName)
|
||||
url.searchParams.set('newTab', String(toolName === 'browser_navigate'));
|
||||
url.searchParams.set('pwMcpVersion', packageJSON.version);
|
||||
const href = url.toString();
|
||||
|
||||
let executablePath = this._executablePath;
|
||||
if (!executablePath) {
|
||||
const executableInfo = registry.findExecutable(this._browserChannel);
|
||||
if (!executableInfo)
|
||||
throw new Error(`Unsupported channel: "${this._browserChannel}"`);
|
||||
executablePath = executableInfo.executablePath();
|
||||
if (!executablePath)
|
||||
throw new Error(`"${this._browserChannel}" executable not found. Make sure it is installed at a standard location.`);
|
||||
}
|
||||
const executableInfo = registry.findExecutable(this._browserChannel);
|
||||
if (!executableInfo)
|
||||
throw new Error(`Unsupported channel: "${this._browserChannel}"`);
|
||||
const executablePath = executableInfo.executablePath();
|
||||
if (!executablePath)
|
||||
throw new Error(`"${this._browserChannel}" executable not found. Make sure it is installed at a standard location.`);
|
||||
|
||||
const args: string[] = [];
|
||||
if (this._userDataDir)
|
||||
@@ -238,7 +229,7 @@ export class CDPRelayServer {
|
||||
this._extensionConnectionPromise.resolve();
|
||||
}
|
||||
|
||||
private _handleExtensionMessage<M extends keyof ExtensionEvents>(method: M, params: ExtensionEvents[M]['params']) {
|
||||
private _handleExtensionMessage(method: string, params: any) {
|
||||
switch (method) {
|
||||
case 'forwardCDPEvent':
|
||||
const sessionId = params.sessionId || this._connectedTabInfo?.sessionId;
|
||||
@@ -248,6 +239,10 @@ export class CDPRelayServer {
|
||||
params: params.params
|
||||
});
|
||||
break;
|
||||
case 'detachedFromTab':
|
||||
debugLogger('← Debugger detached from tab:', params);
|
||||
this._connectedTabInfo = undefined;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -284,7 +279,7 @@ export class CDPRelayServer {
|
||||
if (sessionId)
|
||||
break;
|
||||
// Simulate auto-attach behavior with real target info
|
||||
const { targetInfo } = await this._extensionConnection!.send('attachToTab', { });
|
||||
const { targetInfo } = await this._extensionConnection!.send('attachToTab');
|
||||
this._connectedTabInfo = {
|
||||
targetInfo,
|
||||
sessionId: `pw-tab-${this._nextSessionId++}`,
|
||||
@@ -338,7 +333,7 @@ class ExtensionConnection {
|
||||
private readonly _callbacks = new Map<number, { resolve: (o: any) => void, reject: (e: Error) => void, error: Error }>();
|
||||
private _lastId = 0;
|
||||
|
||||
onmessage?: <M extends keyof ExtensionEvents>(method: M, params: ExtensionEvents[M]['params']) => void;
|
||||
onmessage?: (method: string, params: any) => void;
|
||||
onclose?: (self: ExtensionConnection, reason: string) => void;
|
||||
|
||||
constructor(ws: WebSocket) {
|
||||
@@ -348,11 +343,11 @@ class ExtensionConnection {
|
||||
this._ws.on('error', this._onError.bind(this));
|
||||
}
|
||||
|
||||
async send<M extends keyof ExtensionCommand>(method: M, params: ExtensionCommand[M]['params']): Promise<any> {
|
||||
async send(method: string, params?: any, sessionId?: string): Promise<any> {
|
||||
if (this._ws.readyState !== WebSocket.OPEN)
|
||||
throw new Error(`Unexpected WebSocket state: ${this._ws.readyState}`);
|
||||
const id = ++this._lastId;
|
||||
this._ws.send(JSON.stringify({ id, method, params }));
|
||||
this._ws.send(JSON.stringify({ id, method, params, sessionId }));
|
||||
const error = new Error(`Protocol error: ${method}`);
|
||||
return new Promise((resolve, reject) => {
|
||||
this._callbacks.set(id, { resolve, reject, error });
|
||||
@@ -397,7 +392,7 @@ class ExtensionConnection {
|
||||
} else if (object.id) {
|
||||
debugLogger('← Extension: unexpected response', object);
|
||||
} else {
|
||||
this.onmessage?.(object.method! as keyof ExtensionEvents, object.params);
|
||||
this.onmessage?.(object.method!, object.params);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -26,16 +26,14 @@ const debugLogger = debug('pw:mcp:relay');
|
||||
export class ExtensionContextFactory implements BrowserContextFactory {
|
||||
private _browserChannel: string;
|
||||
private _userDataDir?: string;
|
||||
private _executablePath?: string;
|
||||
|
||||
constructor(browserChannel: string, userDataDir: string | undefined, executablePath: string | undefined) {
|
||||
constructor(browserChannel: string, userDataDir: string | undefined) {
|
||||
this._browserChannel = browserChannel;
|
||||
this._userDataDir = userDataDir;
|
||||
this._executablePath = executablePath;
|
||||
}
|
||||
|
||||
async createContext(clientInfo: ClientInfo, abortSignal: AbortSignal, toolName: string | undefined): Promise<{ browserContext: playwright.BrowserContext, close: () => Promise<void> }> {
|
||||
const browser = await this._obtainBrowser(clientInfo, abortSignal, toolName);
|
||||
async createContext(clientInfo: ClientInfo, abortSignal: AbortSignal): Promise<{ browserContext: playwright.BrowserContext, close: () => Promise<void> }> {
|
||||
const browser = await this._obtainBrowser(clientInfo, abortSignal);
|
||||
return {
|
||||
browserContext: browser.contexts()[0],
|
||||
close: async () => {
|
||||
@@ -45,9 +43,9 @@ export class ExtensionContextFactory implements BrowserContextFactory {
|
||||
};
|
||||
}
|
||||
|
||||
private async _obtainBrowser(clientInfo: ClientInfo, abortSignal: AbortSignal, toolName: string | undefined): Promise<playwright.Browser> {
|
||||
private async _obtainBrowser(clientInfo: ClientInfo, abortSignal: AbortSignal): Promise<playwright.Browser> {
|
||||
const relay = await this._startRelay(abortSignal);
|
||||
await relay.ensureExtensionConnectionForMCPContext(clientInfo, abortSignal, toolName);
|
||||
await relay.ensureExtensionConnectionForMCPContext(clientInfo, abortSignal);
|
||||
return await playwright.chromium.connectOverCDP(relay.cdpEndpoint());
|
||||
}
|
||||
|
||||
@@ -57,7 +55,7 @@ export class ExtensionContextFactory implements BrowserContextFactory {
|
||||
httpServer.close();
|
||||
throw new Error(abortSignal.reason);
|
||||
}
|
||||
const cdpRelayServer = new CDPRelayServer(httpServer, this._browserChannel, this._userDataDir, this._executablePath);
|
||||
const cdpRelayServer = new CDPRelayServer(httpServer, this._browserChannel, this._userDataDir);
|
||||
abortSignal.addEventListener('abort', () => cdpRelayServer.stop());
|
||||
debugLogger(`CDP relay server started, extension endpoint: ${cdpRelayServer.extensionEndpoint()}.`);
|
||||
return cdpRelayServer;
|
||||
|
||||
@@ -1,42 +0,0 @@
|
||||
/**
|
||||
* Copyright (c) Microsoft Corporation.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
// Whenever the commands/events change, the version must be updated. The latest
|
||||
// extension version should be compatible with the old MCP clients.
|
||||
export const VERSION = 1;
|
||||
|
||||
export type ExtensionCommand = {
|
||||
'attachToTab': {
|
||||
params: {};
|
||||
};
|
||||
'forwardCDPCommand': {
|
||||
params: {
|
||||
method: string,
|
||||
sessionId?: string
|
||||
params?: any,
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
export type ExtensionEvents = {
|
||||
'forwardCDPEvent': {
|
||||
params: {
|
||||
method: string,
|
||||
sessionId?: string
|
||||
params?: any,
|
||||
};
|
||||
};
|
||||
};
|
||||
@@ -32,7 +32,6 @@ const testDebug = debug('pw:mcp:test');
|
||||
export async function startHttpServer(config: { host?: string, port?: number }, abortSignal?: AbortSignal): Promise<http.Server> {
|
||||
const { host, port } = config;
|
||||
const httpServer = http.createServer();
|
||||
decorateServer(httpServer);
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
httpServer.on('error', reject);
|
||||
abortSignal?.addEventListener('abort', () => {
|
||||
@@ -137,19 +136,3 @@ async function handleStreamable(serverBackendFactory: ServerBackendFactory, req:
|
||||
res.statusCode = 400;
|
||||
res.end('Invalid request');
|
||||
}
|
||||
|
||||
function decorateServer(server: net.Server) {
|
||||
const sockets = new Set<net.Socket>();
|
||||
server.on('connection', socket => {
|
||||
sockets.add(socket);
|
||||
socket.once('close', () => sockets.delete(socket));
|
||||
});
|
||||
|
||||
const close = server.close;
|
||||
server.close = (callback?: (err?: Error) => void) => {
|
||||
for (const socket of sockets)
|
||||
socket.destroy();
|
||||
sockets.clear();
|
||||
return close.call(server, callback);
|
||||
};
|
||||
}
|
||||
|
||||
239
src/mcp/mdb.ts
239
src/mcp/mdb.ts
@@ -1,239 +0,0 @@
|
||||
/**
|
||||
* Copyright (c) Microsoft Corporation.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import debug from 'debug';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
|
||||
import { PingRequestSchema } from '@modelcontextprotocol/sdk/types.js';
|
||||
import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js';
|
||||
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
||||
|
||||
import { defineToolSchema } from './tool.js';
|
||||
import * as mcpServer from './server.js';
|
||||
import * as mcpHttp from './http.js';
|
||||
import { wrapInProcess } from './server.js';
|
||||
import { ManualPromise } from './manualPromise.js';
|
||||
|
||||
import type { Transport } from '@modelcontextprotocol/sdk/shared/transport.js';
|
||||
|
||||
const mdbDebug = debug('pw:mcp:mdb');
|
||||
const errorsDebug = debug('pw:mcp:errors');
|
||||
|
||||
export class MDBBackend implements mcpServer.ServerBackend {
|
||||
private _stack: { client: Client, toolNames: string[], resultPromise: ManualPromise<mcpServer.CallToolResult> | undefined }[] = [];
|
||||
private _interruptPromise: ManualPromise<mcpServer.CallToolResult> | undefined;
|
||||
private _topLevelBackend: mcpServer.ServerBackend;
|
||||
private _initialized = false;
|
||||
|
||||
constructor(topLevelBackend: mcpServer.ServerBackend) {
|
||||
this._topLevelBackend = topLevelBackend;
|
||||
}
|
||||
|
||||
async initialize(server: mcpServer.Server): Promise<void> {
|
||||
if (this._initialized)
|
||||
return;
|
||||
this._initialized = true;
|
||||
const transport = await wrapInProcess(this._topLevelBackend);
|
||||
await this._pushClient(transport);
|
||||
}
|
||||
|
||||
async listTools(): Promise<mcpServer.Tool[]> {
|
||||
const response = await this._client().listTools();
|
||||
return response.tools;
|
||||
}
|
||||
|
||||
async callTool(name: string, args: mcpServer.CallToolRequest['params']['arguments']): Promise<mcpServer.CallToolResult> {
|
||||
if (name === pushToolsSchema.name)
|
||||
return await this._pushTools(pushToolsSchema.inputSchema.parse(args || {}));
|
||||
|
||||
const interruptPromise = new ManualPromise<mcpServer.CallToolResult>();
|
||||
this._interruptPromise = interruptPromise;
|
||||
let [entry] = this._stack;
|
||||
|
||||
// Pop the client while the tool is not found.
|
||||
while (entry && !entry.toolNames.includes(name)) {
|
||||
mdbDebug('popping client from stack for ', name);
|
||||
this._stack.shift();
|
||||
await entry.client.close();
|
||||
entry = this._stack[0];
|
||||
}
|
||||
if (!entry)
|
||||
throw new Error(`Tool ${name} not found in the tool stack`);
|
||||
|
||||
const resultPromise = new ManualPromise<mcpServer.CallToolResult>();
|
||||
entry.resultPromise = resultPromise;
|
||||
|
||||
this._client().callTool({
|
||||
name,
|
||||
arguments: args,
|
||||
}).then(result => {
|
||||
resultPromise.resolve(result as mcpServer.CallToolResult);
|
||||
}).catch(e => {
|
||||
mdbDebug('error in client call', e);
|
||||
if (this._stack.length < 2)
|
||||
throw e;
|
||||
this._stack.shift();
|
||||
const prevEntry = this._stack[0];
|
||||
void prevEntry.resultPromise!.then(result => resultPromise.resolve(result));
|
||||
});
|
||||
const result = await Promise.race([interruptPromise, resultPromise]);
|
||||
if (interruptPromise.isDone())
|
||||
mdbDebug('client call intercepted', result);
|
||||
else
|
||||
mdbDebug('client call result', result);
|
||||
return result;
|
||||
}
|
||||
|
||||
private _client(): Client {
|
||||
const [entry] = this._stack;
|
||||
if (!entry)
|
||||
throw new Error('No debugging backend available');
|
||||
return entry.client;
|
||||
}
|
||||
|
||||
private async _pushTools(params: { mcpUrl: string, introMessage?: string }): Promise<mcpServer.CallToolResult> {
|
||||
mdbDebug('pushing tools to the stack', params.mcpUrl);
|
||||
const transport = new StreamableHTTPClientTransport(new URL(params.mcpUrl));
|
||||
await this._pushClient(transport, params.introMessage);
|
||||
return { content: [{ type: 'text', text: 'Tools pushed' }] };
|
||||
}
|
||||
|
||||
private async _pushClient(transport: Transport, introMessage?: string): Promise<mcpServer.CallToolResult> {
|
||||
mdbDebug('pushing client to the stack');
|
||||
const client = new Client({ name: 'Internal client', version: '0.0.0' });
|
||||
client.setRequestHandler(PingRequestSchema, () => ({}));
|
||||
await client.connect(transport);
|
||||
mdbDebug('connected to the new client');
|
||||
const { tools } = await client.listTools();
|
||||
this._stack.unshift({ client, toolNames: tools.map(tool => tool.name), resultPromise: undefined });
|
||||
mdbDebug('new tools added to the stack:', tools.map(tool => tool.name));
|
||||
mdbDebug('interrupting current call:', !!this._interruptPromise);
|
||||
this._interruptPromise?.resolve({
|
||||
content: [{
|
||||
type: 'text',
|
||||
text: introMessage || '',
|
||||
}],
|
||||
});
|
||||
this._interruptPromise = undefined;
|
||||
return { content: [{ type: 'text', text: 'Tools pushed' }] };
|
||||
}
|
||||
}
|
||||
|
||||
const pushToolsSchema = defineToolSchema({
|
||||
name: 'mdb_push_tools',
|
||||
title: 'Push MCP tools to the tools stack',
|
||||
description: 'Push MCP tools to the tools stack',
|
||||
inputSchema: z.object({
|
||||
mcpUrl: z.string(),
|
||||
introMessage: z.string().optional(),
|
||||
}),
|
||||
type: 'readOnly',
|
||||
});
|
||||
|
||||
export type ServerBackendOnPause = mcpServer.ServerBackend & {
|
||||
requestSelfDestruct?: () => void;
|
||||
};
|
||||
|
||||
export async function runMainBackend(backendFactory: mcpServer.ServerBackendFactory, options?: { port?: number }): Promise<string | undefined> {
|
||||
const mdbBackend = new MDBBackend(backendFactory.create());
|
||||
// Start HTTP unconditionally.
|
||||
const factory: mcpServer.ServerBackendFactory = {
|
||||
...backendFactory,
|
||||
create: () => mdbBackend
|
||||
};
|
||||
const url = await startAsHttp(factory, { port: options?.port || 0 });
|
||||
process.env.PLAYWRIGHT_DEBUGGER_MCP = url;
|
||||
|
||||
if (options?.port !== undefined)
|
||||
return url;
|
||||
|
||||
// Start stdio conditionally.
|
||||
await mcpServer.connect(factory, new StdioServerTransport(), false);
|
||||
}
|
||||
|
||||
export async function runOnPauseBackendLoop(mdbUrl: string, backend: ServerBackendOnPause, introMessage: string) {
|
||||
const wrappedBackend = new OnceTimeServerBackendWrapper(backend);
|
||||
|
||||
const factory = {
|
||||
name: 'on-pause-backend',
|
||||
nameInConfig: 'on-pause-backend',
|
||||
version: '0.0.0',
|
||||
create: () => wrappedBackend,
|
||||
};
|
||||
|
||||
const httpServer = await mcpHttp.startHttpServer({ port: 0 });
|
||||
await mcpHttp.installHttpTransport(httpServer, factory);
|
||||
const url = mcpHttp.httpAddressToString(httpServer.address());
|
||||
|
||||
const client = new Client({ name: 'Internal client', version: '0.0.0' });
|
||||
client.setRequestHandler(PingRequestSchema, () => ({}));
|
||||
const transport = new StreamableHTTPClientTransport(new URL(mdbUrl));
|
||||
await client.connect(transport);
|
||||
|
||||
const pushToolsResult = await client.callTool({
|
||||
name: pushToolsSchema.name,
|
||||
arguments: {
|
||||
mcpUrl: url,
|
||||
introMessage,
|
||||
},
|
||||
});
|
||||
if (pushToolsResult.isError)
|
||||
errorsDebug('Failed to push tools', pushToolsResult.content);
|
||||
await transport.terminateSession();
|
||||
await client.close();
|
||||
|
||||
await wrappedBackend.waitForClosed();
|
||||
httpServer.close();
|
||||
}
|
||||
|
||||
async function startAsHttp(backendFactory: mcpServer.ServerBackendFactory, options: { port: number }) {
|
||||
const httpServer = await mcpHttp.startHttpServer(options);
|
||||
await mcpHttp.installHttpTransport(httpServer, backendFactory);
|
||||
return mcpHttp.httpAddressToString(httpServer.address());
|
||||
}
|
||||
|
||||
|
||||
class OnceTimeServerBackendWrapper implements mcpServer.ServerBackend {
|
||||
private _backend: ServerBackendOnPause;
|
||||
private _selfDestructPromise = new ManualPromise<void>();
|
||||
|
||||
constructor(backend: ServerBackendOnPause) {
|
||||
this._backend = backend;
|
||||
this._backend.requestSelfDestruct = () => this._selfDestructPromise.resolve();
|
||||
}
|
||||
|
||||
async initialize(server: mcpServer.Server, clientVersion: mcpServer.ClientVersion, roots: mcpServer.Root[]): Promise<void> {
|
||||
await this._backend.initialize?.(server, clientVersion, roots);
|
||||
}
|
||||
|
||||
async listTools(): Promise<mcpServer.Tool[]> {
|
||||
return this._backend.listTools();
|
||||
}
|
||||
|
||||
async callTool(name: string, args: mcpServer.CallToolRequest['params']['arguments']): Promise<mcpServer.CallToolResult> {
|
||||
return this._backend.callTool(name, args);
|
||||
}
|
||||
|
||||
serverClosed(server: mcpServer.Server) {
|
||||
this._backend.serverClosed?.(server);
|
||||
this._selfDestructPromise.resolve();
|
||||
}
|
||||
|
||||
async waitForClosed() {
|
||||
await this._selfDestructPromise;
|
||||
}
|
||||
}
|
||||
@@ -21,7 +21,7 @@ import { zodToJsonSchema } from 'zod-to-json-schema';
|
||||
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
|
||||
import { ListRootsRequestSchema, PingRequestSchema } from '@modelcontextprotocol/sdk/types.js';
|
||||
|
||||
import type { ServerBackend, ClientVersion, Root, Server } from './server.js';
|
||||
import type { ServerBackend, ClientVersion, Root } from './server.js';
|
||||
import type { Transport } from '@modelcontextprotocol/sdk/shared/transport.js';
|
||||
import type { Tool, CallToolResult, CallToolRequest } from '@modelcontextprotocol/sdk/types.js';
|
||||
|
||||
@@ -44,7 +44,7 @@ export class ProxyBackend implements ServerBackend {
|
||||
this._contextSwitchTool = this._defineContextSwitchTool();
|
||||
}
|
||||
|
||||
async initialize(server: Server, clientVersion: ClientVersion, roots: Root[]): Promise<void> {
|
||||
async initialize(clientVersion: ClientVersion, roots: Root[]): Promise<void> {
|
||||
this._roots = roots;
|
||||
await this._setCurrentClient(this._mcpProviders[0]);
|
||||
}
|
||||
|
||||
@@ -31,12 +31,11 @@ const serverDebug = debug('pw:mcp:server');
|
||||
const errorsDebug = debug('pw:mcp:errors');
|
||||
|
||||
export type ClientVersion = { name: string, version: string };
|
||||
|
||||
export interface ServerBackend {
|
||||
initialize?(server: Server, clientVersion: ClientVersion, roots: Root[]): Promise<void>;
|
||||
initialize?(clientVersion: ClientVersion, roots: Root[]): Promise<void>;
|
||||
listTools(): Promise<Tool[]>;
|
||||
callTool(name: string, args: CallToolRequest['params']['arguments']): Promise<CallToolResult>;
|
||||
serverClosed?(server: Server): void;
|
||||
serverClosed?(): void;
|
||||
}
|
||||
|
||||
export type ServerBackendFactory = {
|
||||
@@ -100,13 +99,13 @@ export function createServer(name: string, version: string, backend: ServerBacke
|
||||
clientRoots = roots;
|
||||
}
|
||||
const clientVersion = server.getClientVersion() ?? { name: 'unknown', version: 'unknown' };
|
||||
await backend.initialize?.(server, clientVersion, clientRoots);
|
||||
await backend.initialize?.(clientVersion, clientRoots);
|
||||
initializedPromiseResolve();
|
||||
} catch (e) {
|
||||
errorsDebug(e);
|
||||
}
|
||||
});
|
||||
addServerListener(server, 'close', () => backend.serverClosed?.(server));
|
||||
addServerListener(server, 'close', () => backend.serverClosed?.());
|
||||
return server;
|
||||
}
|
||||
|
||||
|
||||
@@ -40,7 +40,3 @@ export function toMcpTool(tool: ToolSchema<any>): mcpServer.Tool {
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function defineToolSchema<Input extends z.Schema>(tool: ToolSchema<Input>): ToolSchema<Input> {
|
||||
return tool;
|
||||
}
|
||||
|
||||
@@ -72,7 +72,7 @@ program
|
||||
|
||||
const config = await resolveCLIConfig(options);
|
||||
const browserContextFactory = contextFactory(config);
|
||||
const extensionContextFactory = new ExtensionContextFactory(config.browser.launchOptions.channel || 'chrome', config.browser.userDataDir, config.browser.launchOptions.executablePath);
|
||||
const extensionContextFactory = new ExtensionContextFactory(config.browser.launchOptions.channel || 'chrome', config.browser.userDataDir);
|
||||
|
||||
if (options.extension) {
|
||||
const serverBackendFactory: mcpServer.ServerBackendFactory = {
|
||||
|
||||
@@ -18,7 +18,7 @@ import { EventEmitter } from 'events';
|
||||
import * as playwright from 'playwright';
|
||||
import { callOnPageNoTrace, waitForCompletion } from './tools/utils.js';
|
||||
import { logUnhandledError } from './utils/log.js';
|
||||
import { ManualPromise } from './mcp/manualPromise.js';
|
||||
import { ManualPromise } from './utils/manualPromise.js';
|
||||
import { ModalState } from './tools/tool.js';
|
||||
|
||||
import type { Context } from './context.js';
|
||||
|
||||
@@ -19,10 +19,8 @@ import console from './tools/console.js';
|
||||
import dialogs from './tools/dialogs.js';
|
||||
import evaluate from './tools/evaluate.js';
|
||||
import files from './tools/files.js';
|
||||
import form from './tools/form.js';
|
||||
import install from './tools/install.js';
|
||||
import keyboard from './tools/keyboard.js';
|
||||
import mouse from './tools/mouse.js';
|
||||
import navigate from './tools/navigate.js';
|
||||
import network from './tools/network.js';
|
||||
import pdf from './tools/pdf.js';
|
||||
@@ -30,7 +28,7 @@ import snapshot from './tools/snapshot.js';
|
||||
import tabs from './tools/tabs.js';
|
||||
import screenshot from './tools/screenshot.js';
|
||||
import wait from './tools/wait.js';
|
||||
import verify from './tools/verify.js';
|
||||
import mouse from './tools/mouse.js';
|
||||
|
||||
import type { Tool } from './tools/tool.js';
|
||||
import type { FullConfig } from './config.js';
|
||||
@@ -41,7 +39,6 @@ export const allTools: Tool<any>[] = [
|
||||
...dialogs,
|
||||
...evaluate,
|
||||
...files,
|
||||
...form,
|
||||
...install,
|
||||
...keyboard,
|
||||
...navigate,
|
||||
@@ -52,7 +49,6 @@ export const allTools: Tool<any>[] = [
|
||||
...snapshot,
|
||||
...tabs,
|
||||
...wait,
|
||||
...verify,
|
||||
];
|
||||
|
||||
export function filteredTools(config: FullConfig) {
|
||||
|
||||
@@ -1,61 +0,0 @@
|
||||
/**
|
||||
* Copyright (c) Microsoft Corporation.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { z } from 'zod';
|
||||
|
||||
import { defineTabTool } from './tool.js';
|
||||
import { generateLocator } from './utils.js';
|
||||
import * as javascript from '../utils/codegen.js';
|
||||
|
||||
const fillForm = defineTabTool({
|
||||
capability: 'core',
|
||||
|
||||
schema: {
|
||||
name: 'browser_fill_form',
|
||||
title: 'Fill form',
|
||||
description: 'Fill multiple form fields',
|
||||
inputSchema: z.object({
|
||||
fields: z.array(z.object({
|
||||
name: z.string().describe('Human-readable field name'),
|
||||
type: z.enum(['textbox', 'checkbox', 'radio', 'combobox', 'slider']).describe('Type of the field'),
|
||||
ref: z.string().describe('Exact target field reference from the page snapshot'),
|
||||
value: z.string().describe('Value to fill in the field. If the field is a checkbox, the value should be `true` or `false`. If the field is a combobox, the value should be the text of the option.'),
|
||||
})).describe('Fields to fill in'),
|
||||
}),
|
||||
type: 'destructive',
|
||||
},
|
||||
|
||||
handle: async (tab, params, response) => {
|
||||
for (const field of params.fields) {
|
||||
const locator = await tab.refLocator({ element: field.name, ref: field.ref });
|
||||
const locatorSource = `await page.${await generateLocator(locator)}`;
|
||||
if (field.type === 'textbox' || field.type === 'slider') {
|
||||
await locator.fill(field.value);
|
||||
response.addCode(`${locatorSource}.fill(${javascript.quote(field.value)});`);
|
||||
} else if (field.type === 'checkbox' || field.type === 'radio') {
|
||||
await locator.setChecked(field.value === 'true');
|
||||
response.addCode(`${locatorSource}.setChecked(${javascript.quote(field.value)});`);
|
||||
} else if (field.type === 'combobox') {
|
||||
await locator.selectOption({ label: field.value });
|
||||
response.addCode(`${locatorSource}.selectOption(${javascript.quote(field.value)});`);
|
||||
}
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
export default [
|
||||
fillForm,
|
||||
];
|
||||
@@ -56,7 +56,24 @@ const goBack = defineTabTool({
|
||||
},
|
||||
});
|
||||
|
||||
const goForward = defineTabTool({
|
||||
capability: 'core',
|
||||
schema: {
|
||||
name: 'browser_navigate_forward',
|
||||
title: 'Go forward',
|
||||
description: 'Go forward to the next page',
|
||||
inputSchema: z.object({}),
|
||||
type: 'readOnly',
|
||||
},
|
||||
handle: async (tab, params, response) => {
|
||||
await tab.page.goForward();
|
||||
response.setIncludeSnapshot();
|
||||
response.addCode(`await page.goForward();`);
|
||||
},
|
||||
});
|
||||
|
||||
export default [
|
||||
navigate,
|
||||
goBack,
|
||||
goForward,
|
||||
];
|
||||
|
||||
@@ -17,48 +17,85 @@
|
||||
import { z } from 'zod';
|
||||
import { defineTool } from './tool.js';
|
||||
|
||||
const browserTabs = defineTool({
|
||||
const listTabs = defineTool({
|
||||
capability: 'core-tabs',
|
||||
|
||||
schema: {
|
||||
name: 'browser_tabs',
|
||||
title: 'Manage tabs',
|
||||
description: 'List, create, close, or select a browser tab.',
|
||||
name: 'browser_tab_list',
|
||||
title: 'List tabs',
|
||||
description: 'List browser tabs',
|
||||
inputSchema: z.object({}),
|
||||
type: 'readOnly',
|
||||
},
|
||||
|
||||
handle: async (context, params, response) => {
|
||||
await context.ensureTab();
|
||||
response.setIncludeTabs();
|
||||
},
|
||||
});
|
||||
|
||||
const selectTab = defineTool({
|
||||
capability: 'core-tabs',
|
||||
|
||||
schema: {
|
||||
name: 'browser_tab_select',
|
||||
title: 'Select a tab',
|
||||
description: 'Select a tab by index',
|
||||
inputSchema: z.object({
|
||||
action: z.enum(['list', 'new', 'close', 'select']).describe('Operation to perform'),
|
||||
index: z.number().optional().describe('Tab index, used for close/select. If omitted for close, current tab is closed.'),
|
||||
index: z.number().describe('The index of the tab to select'),
|
||||
}),
|
||||
type: 'readOnly',
|
||||
},
|
||||
|
||||
handle: async (context, params, response) => {
|
||||
await context.selectTab(params.index);
|
||||
response.setIncludeSnapshot();
|
||||
},
|
||||
});
|
||||
|
||||
const newTab = defineTool({
|
||||
capability: 'core-tabs',
|
||||
|
||||
schema: {
|
||||
name: 'browser_tab_new',
|
||||
title: 'Open a new tab',
|
||||
description: 'Open a new tab',
|
||||
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.'),
|
||||
}),
|
||||
type: 'readOnly',
|
||||
},
|
||||
|
||||
handle: async (context, params, response) => {
|
||||
const tab = await context.newTab();
|
||||
if (params.url)
|
||||
await tab.navigate(params.url);
|
||||
response.setIncludeSnapshot();
|
||||
},
|
||||
});
|
||||
|
||||
const closeTab = defineTool({
|
||||
capability: 'core-tabs',
|
||||
|
||||
schema: {
|
||||
name: 'browser_tab_close',
|
||||
title: 'Close a tab',
|
||||
description: 'Close a tab',
|
||||
inputSchema: z.object({
|
||||
index: z.number().optional().describe('The index of the tab to close. Closes current tab if not provided.'),
|
||||
}),
|
||||
type: 'destructive',
|
||||
},
|
||||
|
||||
handle: async (context, params, response) => {
|
||||
switch (params.action) {
|
||||
case 'list': {
|
||||
await context.ensureTab();
|
||||
response.setIncludeTabs();
|
||||
return;
|
||||
}
|
||||
case 'new': {
|
||||
await context.newTab();
|
||||
response.setIncludeTabs();
|
||||
return;
|
||||
}
|
||||
case 'close': {
|
||||
await context.closeTab(params.index);
|
||||
response.setIncludeSnapshot();
|
||||
return;
|
||||
}
|
||||
case 'select': {
|
||||
if (params.index === undefined)
|
||||
throw new Error('Tab index is required');
|
||||
await context.selectTab(params.index);
|
||||
response.setIncludeSnapshot();
|
||||
return;
|
||||
}
|
||||
}
|
||||
await context.closeTab(params.index);
|
||||
response.setIncludeSnapshot();
|
||||
},
|
||||
});
|
||||
|
||||
export default [
|
||||
browserTabs,
|
||||
listTabs,
|
||||
newTab,
|
||||
selectTab,
|
||||
closeTab,
|
||||
];
|
||||
|
||||
@@ -1,149 +0,0 @@
|
||||
/**
|
||||
* Copyright (c) Microsoft Corporation.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { z } from 'zod';
|
||||
|
||||
import { defineTabTool } from './tool.js';
|
||||
import * as javascript from '../utils/codegen.js';
|
||||
import { generateLocator } from './utils.js';
|
||||
|
||||
const verifyElement = defineTabTool({
|
||||
capability: 'verify',
|
||||
schema: {
|
||||
name: 'browser_verify_element_visible',
|
||||
title: 'Verify element visible',
|
||||
description: 'Verify element is visible on the page',
|
||||
inputSchema: z.object({
|
||||
role: z.string().describe('ROLE of the element. Can be found in the snapshot like this: \`- {ROLE} "Accessible Name":\`'),
|
||||
accessibleName: z.string().describe('ACCESSIBLE_NAME of the element. Can be found in the snapshot like this: \`- role "{ACCESSIBLE_NAME}"\`'),
|
||||
}),
|
||||
type: 'readOnly',
|
||||
},
|
||||
|
||||
handle: async (tab, params, response) => {
|
||||
const locator = tab.page.getByRole(params.role as any, { name: params.accessibleName });
|
||||
if (await locator.count() === 0) {
|
||||
response.addError(`Element with role "${params.role}" and accessible name "${params.accessibleName}" not found`);
|
||||
return;
|
||||
}
|
||||
|
||||
response.addCode(`await expect(page.getByRole(${javascript.escapeWithQuotes(params.role)}, { name: ${javascript.escapeWithQuotes(params.accessibleName)} })).toBeVisible();`);
|
||||
response.addResult('Done');
|
||||
},
|
||||
});
|
||||
|
||||
const verifyText = defineTabTool({
|
||||
capability: 'verify',
|
||||
schema: {
|
||||
name: 'browser_verify_text_visible',
|
||||
title: 'Verify text visible',
|
||||
description: `Verify text is visible on the page. Prefer ${verifyElement.schema.name} if possible.`,
|
||||
inputSchema: z.object({
|
||||
text: z.string().describe('TEXT to verify. Can be found in the snapshot like this: \`- role "Accessible Name": {TEXT}\` or like this: \`- text: {TEXT}\`'),
|
||||
}),
|
||||
type: 'readOnly',
|
||||
},
|
||||
|
||||
handle: async (tab, params, response) => {
|
||||
const locator = tab.page.getByText(params.text).filter({ visible: true });
|
||||
if (await locator.count() === 0) {
|
||||
response.addError('Text not found');
|
||||
return;
|
||||
}
|
||||
|
||||
response.addCode(`await expect(page.getByText(${javascript.escapeWithQuotes(params.text)})).toBeVisible();`);
|
||||
response.addResult('Done');
|
||||
},
|
||||
});
|
||||
|
||||
const verifyList = defineTabTool({
|
||||
capability: 'verify',
|
||||
schema: {
|
||||
name: 'browser_verify_list_visible',
|
||||
title: 'Verify list visible',
|
||||
description: 'Verify list is visible on the page',
|
||||
inputSchema: z.object({
|
||||
element: z.string().describe('Human-readable list description'),
|
||||
ref: z.string().describe('Exact target element reference that points to the list'),
|
||||
items: z.array(z.string()).describe('Items to verify'),
|
||||
}),
|
||||
type: 'readOnly',
|
||||
},
|
||||
|
||||
handle: async (tab, params, response) => {
|
||||
const locator = await tab.refLocator({ ref: params.ref, element: params.element });
|
||||
const itemTexts: string[] = [];
|
||||
for (const item of params.items) {
|
||||
const itemLocator = locator.getByText(item);
|
||||
if (await itemLocator.count() === 0) {
|
||||
response.addError(`Item "${item}" not found`);
|
||||
return;
|
||||
}
|
||||
itemTexts.push((await itemLocator.textContent())!);
|
||||
}
|
||||
const ariaSnapshot = `\`
|
||||
- list:
|
||||
${itemTexts.map(t => ` - listitem: ${javascript.escapeWithQuotes(t, '"')}`).join('\n')}
|
||||
\``;
|
||||
response.addCode(`await expect(page.locator('body')).toMatchAriaSnapshot(${ariaSnapshot});`);
|
||||
response.addResult('Done');
|
||||
},
|
||||
});
|
||||
|
||||
const verifyValue = defineTabTool({
|
||||
capability: 'verify',
|
||||
schema: {
|
||||
name: 'browser_verify_value',
|
||||
title: 'Verify value',
|
||||
description: 'Verify element value',
|
||||
inputSchema: z.object({
|
||||
type: z.enum(['textbox', 'checkbox', 'radio', 'combobox', 'slider']).describe('Type of the element'),
|
||||
element: z.string().describe('Human-readable element description'),
|
||||
ref: z.string().describe('Exact target element reference that points to the element'),
|
||||
value: z.string().describe('Value to verify. For checkbox, use "true" or "false".'),
|
||||
}),
|
||||
type: 'readOnly',
|
||||
},
|
||||
|
||||
handle: async (tab, params, response) => {
|
||||
const locator = await tab.refLocator({ ref: params.ref, element: params.element });
|
||||
const locatorSource = `page.${await generateLocator(locator)}`;
|
||||
if (params.type === 'textbox' || params.type === 'slider' || params.type === 'combobox') {
|
||||
const value = await locator.inputValue();
|
||||
if (value !== params.value) {
|
||||
response.addError(`Expected value "${params.value}", but got "${value}"`);
|
||||
return;
|
||||
}
|
||||
response.addCode(`await expect(${locatorSource}).toHaveValue(${javascript.quote(params.value)});`);
|
||||
} else if (params.type === 'checkbox' || params.type === 'radio') {
|
||||
const value = await locator.isChecked();
|
||||
if (value !== (params.value === 'true')) {
|
||||
response.addError(`Expected value "${params.value}", but got "${value}"`);
|
||||
return;
|
||||
}
|
||||
const matcher = value ? 'toBeChecked' : 'not.toBeChecked';
|
||||
response.addCode(`await expect(${locatorSource}).${matcher}();`);
|
||||
}
|
||||
response.addResult('Done');
|
||||
},
|
||||
});
|
||||
|
||||
export default [
|
||||
verifyElement,
|
||||
verifyText,
|
||||
verifyList,
|
||||
verifyValue,
|
||||
];
|
||||
@@ -14,8 +14,6 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { fileURLToPath } from 'url';
|
||||
import path from 'path';
|
||||
import { z } from 'zod';
|
||||
import { zodToJsonSchema } from 'zod-to-json-schema';
|
||||
|
||||
@@ -33,6 +31,8 @@ import { contextFactory } from '../browserContextFactory.js';
|
||||
import type { Transport } from '@modelcontextprotocol/sdk/shared/transport.js';
|
||||
import type { ClientVersion, ServerBackend } from '../mcp/server.js';
|
||||
import type { Root, Tool, CallToolResult, CallToolRequest } from '@modelcontextprotocol/sdk/types.js';
|
||||
import { fileURLToPath } from 'url';
|
||||
import path from 'path';
|
||||
|
||||
const contextSwitchOptions = z.object({
|
||||
connectionString: z.string().optional().describe('The connection string to use to connect to the browser'),
|
||||
@@ -52,7 +52,7 @@ class VSCodeProxyBackend implements ServerBackend {
|
||||
this._contextSwitchTool = this._defineContextSwitchTool();
|
||||
}
|
||||
|
||||
async initialize(server: mcpServer.Server, clientVersion: ClientVersion, roots: Root[]): Promise<void> {
|
||||
async initialize(clientVersion: ClientVersion, roots: Root[]): Promise<void> {
|
||||
this._clientVersion = clientVersion;
|
||||
this._roots = roots;
|
||||
const transport = await this._defaultTransportFactory();
|
||||
@@ -76,7 +76,7 @@ class VSCodeProxyBackend implements ServerBackend {
|
||||
}) as CallToolResult;
|
||||
}
|
||||
|
||||
serverClosed?(server: mcpServer.Server): void {
|
||||
serverClosed?(): void {
|
||||
void this._currentClient?.close().catch(logUnhandledError);
|
||||
}
|
||||
|
||||
|
||||
@@ -24,7 +24,6 @@ test('test snapshot tool list', async ({ client }) => {
|
||||
'browser_drag',
|
||||
'browser_evaluate',
|
||||
'browser_file_upload',
|
||||
'browser_fill_form',
|
||||
'browser_handle_dialog',
|
||||
'browser_hover',
|
||||
'browser_select_option',
|
||||
@@ -32,12 +31,16 @@ test('test snapshot tool list', async ({ client }) => {
|
||||
'browser_close',
|
||||
'browser_install',
|
||||
'browser_navigate_back',
|
||||
'browser_navigate_forward',
|
||||
'browser_navigate',
|
||||
'browser_network_requests',
|
||||
'browser_press_key',
|
||||
'browser_resize',
|
||||
'browser_snapshot',
|
||||
'browser_tabs',
|
||||
'browser_tab_close',
|
||||
'browser_tab_list',
|
||||
'browser_tab_new',
|
||||
'browser_tab_select',
|
||||
'browser_take_screenshot',
|
||||
'browser_wait_for',
|
||||
]));
|
||||
@@ -55,7 +58,6 @@ test('test tool list proxy mode', async ({ startClient }) => {
|
||||
'browser_drag',
|
||||
'browser_evaluate',
|
||||
'browser_file_upload',
|
||||
'browser_fill_form',
|
||||
'browser_handle_dialog',
|
||||
'browser_hover',
|
||||
'browser_select_option',
|
||||
@@ -63,12 +65,16 @@ test('test tool list proxy mode', async ({ startClient }) => {
|
||||
'browser_close',
|
||||
'browser_install',
|
||||
'browser_navigate_back',
|
||||
'browser_navigate_forward',
|
||||
'browser_navigate',
|
||||
'browser_network_requests',
|
||||
'browser_press_key',
|
||||
'browser_resize',
|
||||
'browser_snapshot',
|
||||
'browser_tabs',
|
||||
'browser_tab_close',
|
||||
'browser_tab_list',
|
||||
'browser_tab_new',
|
||||
'browser_tab_select',
|
||||
'browser_take_screenshot',
|
||||
'browser_wait_for',
|
||||
]));
|
||||
|
||||
@@ -57,25 +57,6 @@ test('browser_evaluate (element)', async ({ client, server }) => {
|
||||
});
|
||||
});
|
||||
|
||||
test('browser_evaluate object', async ({ client, server }) => {
|
||||
expect(await client.callTool({
|
||||
name: 'browser_navigate',
|
||||
arguments: { url: server.HELLO_WORLD },
|
||||
})).toHaveResponse({
|
||||
pageState: expect.stringContaining(`- Page Title: Title`),
|
||||
});
|
||||
|
||||
expect(await client.callTool({
|
||||
name: 'browser_evaluate',
|
||||
arguments: {
|
||||
function: '() => ({ title: document.title, url: document.URL })',
|
||||
},
|
||||
})).toHaveResponse({
|
||||
result: JSON.stringify({ title: 'Title', url: server.HELLO_WORLD }, null, 2),
|
||||
code: `await page.evaluate('() => ({ title: document.title, url: document.URL })');`,
|
||||
});
|
||||
});
|
||||
|
||||
test('browser_evaluate (error)', async ({ client, server }) => {
|
||||
expect(await client.callTool({
|
||||
name: 'browser_navigate',
|
||||
|
||||
@@ -31,7 +31,6 @@ import type { Transport } from '@modelcontextprotocol/sdk/shared/transport.js';
|
||||
import type { Stream } from 'stream';
|
||||
|
||||
export type TestOptions = {
|
||||
mcpArgs: string[] | undefined;
|
||||
mcpBrowser: string | undefined;
|
||||
mcpMode: 'docker' | undefined;
|
||||
};
|
||||
@@ -66,19 +65,17 @@ type WorkerFixtures = {
|
||||
|
||||
export const test = baseTest.extend<TestFixtures & TestOptions, WorkerFixtures>({
|
||||
|
||||
mcpArgs: [undefined, { option: true }],
|
||||
|
||||
client: async ({ startClient }, use) => {
|
||||
const { client } = await startClient();
|
||||
await use(client);
|
||||
},
|
||||
|
||||
startClient: async ({ mcpHeadless, mcpBrowser, mcpMode, mcpArgs }, use, testInfo) => {
|
||||
startClient: async ({ mcpHeadless, mcpBrowser, mcpMode }, use, testInfo) => {
|
||||
const configDir = path.dirname(test.info().config.configFile!);
|
||||
const clients: Client[] = [];
|
||||
|
||||
await use(async options => {
|
||||
const args: string[] = mcpArgs ?? [];
|
||||
const args: string[] = [];
|
||||
if (process.env.CI && process.platform === 'linux')
|
||||
args.push('--no-sandbox');
|
||||
if (mcpHeadless)
|
||||
|
||||
@@ -1,123 +0,0 @@
|
||||
/**
|
||||
* Copyright (c) Microsoft Corporation.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { test, expect } from './fixtures.js';
|
||||
|
||||
test('browser_fill_form (textbox)', async ({ client, server }) => {
|
||||
server.setContent('/', `
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<body>
|
||||
<form>
|
||||
<label>
|
||||
<input type="text" id="name" name="name" />
|
||||
Name
|
||||
</label>
|
||||
<label>
|
||||
<input type="email" id="email" name="email" />
|
||||
Email
|
||||
</label>
|
||||
<label>
|
||||
<input type="range" id="age" name="age" min="18" max="100" />
|
||||
Age
|
||||
</label>
|
||||
<label>
|
||||
<select id="country" name="country">
|
||||
<option value="">Choose a country</option>
|
||||
<option value="us">United States</option>
|
||||
<option value="uk">United Kingdom</option>
|
||||
</select>
|
||||
Country
|
||||
</label>
|
||||
<label>
|
||||
<input type="checkbox" name="subscribe" value="newsletter" />
|
||||
Subscribe to newsletter
|
||||
</label>
|
||||
</form>
|
||||
</body>
|
||||
</html>
|
||||
`, 'text/html');
|
||||
|
||||
await client.callTool({
|
||||
name: 'browser_navigate',
|
||||
arguments: { url: server.PREFIX },
|
||||
});
|
||||
|
||||
expect(await client.callTool({
|
||||
name: 'browser_fill_form',
|
||||
arguments: {
|
||||
fields: [
|
||||
{
|
||||
name: 'Name textbox',
|
||||
type: 'textbox',
|
||||
ref: 'e4',
|
||||
value: 'John Doe'
|
||||
},
|
||||
{
|
||||
name: 'Email textbox',
|
||||
type: 'textbox',
|
||||
ref: 'e6',
|
||||
value: 'john.doe@example.com'
|
||||
},
|
||||
{
|
||||
name: 'Age textbox',
|
||||
type: 'slider',
|
||||
ref: 'e8',
|
||||
value: '25'
|
||||
},
|
||||
{
|
||||
name: 'Country select',
|
||||
type: 'combobox',
|
||||
ref: 'e10',
|
||||
value: 'United States'
|
||||
},
|
||||
{
|
||||
name: 'Subscribe checkbox',
|
||||
type: 'checkbox',
|
||||
ref: 'e12',
|
||||
value: 'true'
|
||||
},
|
||||
]
|
||||
},
|
||||
})).toHaveResponse({
|
||||
code: `await page.getByRole('textbox', { name: 'Name' }).fill('John Doe');
|
||||
await page.getByRole('textbox', { name: 'Email' }).fill('john.doe@example.com');
|
||||
await page.getByRole('slider', { name: 'Age' }).fill('25');
|
||||
await page.getByLabel('Choose a country United').selectOption('United States');
|
||||
await page.getByRole('checkbox', { name: 'Subscribe to newsletter' }).setChecked('true');`,
|
||||
});
|
||||
|
||||
const response = await client.callTool({
|
||||
name: 'browser_snapshot',
|
||||
arguments: {
|
||||
},
|
||||
});
|
||||
expect.soft(response).toHaveResponse({
|
||||
pageState: expect.stringMatching(/textbox "Name".*John Doe/),
|
||||
});
|
||||
expect.soft(response).toHaveResponse({
|
||||
pageState: expect.stringMatching(/textbox "Email".*john.doe@example.com/),
|
||||
});
|
||||
expect.soft(response).toHaveResponse({
|
||||
pageState: expect.stringMatching(/slider "Age".*"25"/),
|
||||
});
|
||||
expect.soft(response).toHaveResponse({
|
||||
pageState: expect.stringContaining('option \"United States\" [selected]'),
|
||||
});
|
||||
expect.soft(response).toHaveResponse({
|
||||
pageState: expect.stringContaining('checkbox \"Subscribe to newsletter\" [checked]'),
|
||||
});
|
||||
});
|
||||
@@ -1,217 +0,0 @@
|
||||
/**
|
||||
* Copyright (c) Microsoft Corporation.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { z } from 'zod';
|
||||
import zodToJsonSchema from 'zod-to-json-schema';
|
||||
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
|
||||
import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js';
|
||||
|
||||
import { runMainBackend, runOnPauseBackendLoop } from '../src/mcp/mdb.js';
|
||||
|
||||
import { test, expect } from './fixtures.js';
|
||||
|
||||
import type * as mcpServer from '../src/mcp/server.js';
|
||||
import type { ServerBackendOnPause } from '../src/mcp/mdb.js';
|
||||
|
||||
test('call top level tool', async () => {
|
||||
const { mdbUrl } = await startMDBAndCLI();
|
||||
const mdbClient = await createMDBClient(mdbUrl);
|
||||
|
||||
const { tools } = await mdbClient.client.listTools();
|
||||
expect(tools).toEqual([{
|
||||
name: 'cli_echo',
|
||||
description: 'Echo a message',
|
||||
inputSchema: expect.any(Object),
|
||||
}, {
|
||||
name: 'cli_pause_in_gdb',
|
||||
description: 'Pause in gdb',
|
||||
inputSchema: expect.any(Object),
|
||||
}, {
|
||||
name: 'cli_pause_in_gdb_twice',
|
||||
description: 'Pause in gdb twice',
|
||||
inputSchema: expect.any(Object),
|
||||
}
|
||||
]);
|
||||
|
||||
const echoResult = await mdbClient.client.callTool({
|
||||
name: 'cli_echo',
|
||||
arguments: {
|
||||
message: 'Hello, world!',
|
||||
},
|
||||
});
|
||||
expect(echoResult.content).toEqual([{ type: 'text', text: 'Echo: Hello, world!' }]);
|
||||
|
||||
await mdbClient.close();
|
||||
});
|
||||
|
||||
test('pause on error', async () => {
|
||||
const { mdbUrl } = await startMDBAndCLI();
|
||||
const mdbClient = await createMDBClient(mdbUrl);
|
||||
|
||||
// Make a call that results in a recoverable error.
|
||||
const interruptResult = await mdbClient.client.callTool({
|
||||
name: 'cli_pause_in_gdb',
|
||||
arguments: {},
|
||||
});
|
||||
expect(interruptResult.content).toEqual([{ type: 'text', text: 'Paused on exception' }]);
|
||||
|
||||
// List new inner tools.
|
||||
const { tools } = await mdbClient.client.listTools();
|
||||
expect(tools).toEqual([
|
||||
expect.objectContaining({
|
||||
name: 'gdb_bt',
|
||||
}),
|
||||
expect.objectContaining({
|
||||
name: 'gdb_continue',
|
||||
}),
|
||||
]);
|
||||
|
||||
// Call the new inner tool.
|
||||
const btResult = await mdbClient.client.callTool({
|
||||
name: 'gdb_bt',
|
||||
arguments: {},
|
||||
});
|
||||
expect(btResult.content).toEqual([{ type: 'text', text: 'Backtrace' }]);
|
||||
|
||||
// Continue execution.
|
||||
const continueResult = await mdbClient.client.callTool({
|
||||
name: 'gdb_continue',
|
||||
arguments: {},
|
||||
});
|
||||
expect(continueResult.content).toEqual([{ type: 'text', text: 'Done' }]);
|
||||
|
||||
await mdbClient.close();
|
||||
});
|
||||
|
||||
test('pause on error twice', async () => {
|
||||
const { mdbUrl } = await startMDBAndCLI();
|
||||
const mdbClient = await createMDBClient(mdbUrl);
|
||||
|
||||
// Make a call that results in a recoverable error.
|
||||
const result = await mdbClient.client.callTool({
|
||||
name: 'cli_pause_in_gdb_twice',
|
||||
arguments: {},
|
||||
});
|
||||
expect(result.content).toEqual([{ type: 'text', text: 'Paused on exception 1' }]);
|
||||
|
||||
// Continue execution.
|
||||
const continueResult1 = await mdbClient.client.callTool({
|
||||
name: 'gdb_continue',
|
||||
arguments: {},
|
||||
});
|
||||
expect(continueResult1.content).toEqual([{ type: 'text', text: 'Paused on exception 2' }]);
|
||||
|
||||
const continueResult2 = await mdbClient.client.callTool({
|
||||
name: 'gdb_continue',
|
||||
arguments: {},
|
||||
});
|
||||
expect(continueResult2.content).toEqual([{ type: 'text', text: 'Done' }]);
|
||||
|
||||
await mdbClient.close();
|
||||
});
|
||||
|
||||
async function startMDBAndCLI(): Promise<{ mdbUrl: string }> {
|
||||
const mdbUrlBox = { mdbUrl: undefined as string | undefined };
|
||||
const cliBackendFactory = {
|
||||
name: 'CLI',
|
||||
nameInConfig: 'cli',
|
||||
version: '0.0.0',
|
||||
create: () => new CLIBackend(mdbUrlBox)
|
||||
};
|
||||
|
||||
const mdbUrl = (await runMainBackend(cliBackendFactory, { port: 0 }))!;
|
||||
mdbUrlBox.mdbUrl = mdbUrl;
|
||||
return { mdbUrl };
|
||||
}
|
||||
|
||||
async function createMDBClient(mdbUrl: string): Promise<{ client: Client, close: () => Promise<void> }> {
|
||||
const client = new Client({ name: 'Internal client', version: '0.0.0' });
|
||||
const transport = new StreamableHTTPClientTransport(new URL(mdbUrl));
|
||||
await client.connect(transport);
|
||||
return {
|
||||
client,
|
||||
close: async () => {
|
||||
await transport.terminateSession();
|
||||
await client.close();
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
class CLIBackend implements mcpServer.ServerBackend {
|
||||
constructor(private readonly mdbUrlBox: { mdbUrl: string | undefined }) {}
|
||||
|
||||
async listTools(): Promise<mcpServer.Tool[]> {
|
||||
return [{
|
||||
name: 'cli_echo',
|
||||
description: 'Echo a message',
|
||||
inputSchema: zodToJsonSchema(z.object({ message: z.string() })) as any,
|
||||
}, {
|
||||
name: 'cli_pause_in_gdb',
|
||||
description: 'Pause in gdb',
|
||||
inputSchema: zodToJsonSchema(z.object({})) as any,
|
||||
}, {
|
||||
name: 'cli_pause_in_gdb_twice',
|
||||
description: 'Pause in gdb twice',
|
||||
inputSchema: zodToJsonSchema(z.object({})) as any,
|
||||
}];
|
||||
}
|
||||
|
||||
async callTool(name: string, args: mcpServer.CallToolRequest['params']['arguments']): Promise<mcpServer.CallToolResult> {
|
||||
if (name === 'cli_echo')
|
||||
return { content: [{ type: 'text', text: 'Echo: ' + (args?.message as string) }] };
|
||||
if (name === 'cli_pause_in_gdb') {
|
||||
await runOnPauseBackendLoop(this.mdbUrlBox.mdbUrl!, new GDBBackend(), 'Paused on exception');
|
||||
return { content: [{ type: 'text', text: 'Done' }] };
|
||||
}
|
||||
if (name === 'cli_pause_in_gdb_twice') {
|
||||
await runOnPauseBackendLoop(this.mdbUrlBox.mdbUrl!, new GDBBackend(), 'Paused on exception 1');
|
||||
await runOnPauseBackendLoop(this.mdbUrlBox.mdbUrl!, new GDBBackend(), 'Paused on exception 2');
|
||||
return { content: [{ type: 'text', text: 'Done' }] };
|
||||
}
|
||||
throw new Error(`Unknown tool: ${name}`);
|
||||
}
|
||||
}
|
||||
|
||||
class GDBBackend implements ServerBackendOnPause {
|
||||
private _server!: mcpServer.Server;
|
||||
|
||||
async initialize(server: mcpServer.Server): Promise<void> {
|
||||
this._server = server;
|
||||
}
|
||||
|
||||
async listTools(): Promise<mcpServer.Tool[]> {
|
||||
return [{
|
||||
name: 'gdb_bt',
|
||||
description: 'Print backtrace',
|
||||
inputSchema: zodToJsonSchema(z.object({})) as any,
|
||||
}, {
|
||||
name: 'gdb_continue',
|
||||
description: 'Continue execution',
|
||||
inputSchema: zodToJsonSchema(z.object({})) as any,
|
||||
}];
|
||||
}
|
||||
|
||||
async callTool(name: string, args: mcpServer.CallToolRequest['params']['arguments']): Promise<mcpServer.CallToolResult> {
|
||||
if (name === 'gdb_bt')
|
||||
return { content: [{ type: 'text', text: 'Backtrace' }] };
|
||||
if (name === 'gdb_continue') {
|
||||
(this as ServerBackendOnPause).requestSelfDestruct?.();
|
||||
// Stall
|
||||
await new Promise(f => setTimeout(f, 1000));
|
||||
}
|
||||
throw new Error(`Unknown tool: ${name}`);
|
||||
}
|
||||
}
|
||||
@@ -68,12 +68,12 @@ test('check that trace is saved in workspace', async ({ startClient, server }, t
|
||||
expect(file).toContain('traces');
|
||||
});
|
||||
|
||||
test('should list all tools when listRoots is slow', async ({ startClient }) => {
|
||||
test('should list all tools when listRoots is slow', async ({ startClient, server }, testInfo) => {
|
||||
const { client } = await startClient({
|
||||
clientName: 'Another custom client',
|
||||
roots: [],
|
||||
rootsResponseDelay: 1000,
|
||||
});
|
||||
const tools = await client.listTools();
|
||||
expect(tools.tools.length).toBeGreaterThan(10);
|
||||
expect(tools.tools.length).toBeGreaterThan(20);
|
||||
});
|
||||
|
||||
@@ -300,10 +300,7 @@ test('browser_take_screenshot (viewport without snapshot)', async ({ startClient
|
||||
|
||||
// Ensure we have a tab but don't navigate anywhere (no snapshot captured)
|
||||
expect(await client.callTool({
|
||||
name: 'browser_tabs',
|
||||
arguments: {
|
||||
action: 'list',
|
||||
},
|
||||
name: 'browser_tab_list',
|
||||
})).toHaveResponse({
|
||||
tabs: `- 0: (current) [] (about:blank)`,
|
||||
});
|
||||
|
||||
@@ -19,14 +19,8 @@ import { test, expect } from './fixtures.js';
|
||||
import type { Client } from '@modelcontextprotocol/sdk/client/index.js';
|
||||
|
||||
async function createTab(client: Client, title: string, body: string) {
|
||||
await client.callTool({
|
||||
name: 'browser_tabs',
|
||||
arguments: {
|
||||
action: 'new',
|
||||
},
|
||||
});
|
||||
return await client.callTool({
|
||||
name: 'browser_navigate',
|
||||
name: 'browser_tab_new',
|
||||
arguments: {
|
||||
url: `data:text/html,<title>${title}</title><body>${body}</body>`,
|
||||
},
|
||||
@@ -35,10 +29,7 @@ async function createTab(client: Client, title: string, body: string) {
|
||||
|
||||
test('list initial tabs', async ({ client }) => {
|
||||
expect(await client.callTool({
|
||||
name: 'browser_tabs',
|
||||
arguments: {
|
||||
action: 'list',
|
||||
},
|
||||
name: 'browser_tab_list',
|
||||
})).toHaveResponse({
|
||||
tabs: `- 0: (current) [] (about:blank)`,
|
||||
});
|
||||
@@ -47,10 +38,7 @@ test('list initial tabs', async ({ client }) => {
|
||||
test('list first tab', async ({ client }) => {
|
||||
await createTab(client, 'Tab one', 'Body one');
|
||||
expect(await client.callTool({
|
||||
name: 'browser_tabs',
|
||||
arguments: {
|
||||
action: 'list',
|
||||
},
|
||||
name: 'browser_tab_list',
|
||||
})).toHaveResponse({
|
||||
tabs: `- 0: [] (about:blank)
|
||||
- 1: (current) [Tab one] (data:text/html,<title>Tab one</title><body>Body one</body>)`,
|
||||
@@ -87,9 +75,8 @@ test('select tab', async ({ client }) => {
|
||||
await createTab(client, 'Tab two', 'Body two');
|
||||
|
||||
expect(await client.callTool({
|
||||
name: 'browser_tabs',
|
||||
name: 'browser_tab_select',
|
||||
arguments: {
|
||||
action: 'select',
|
||||
index: 1,
|
||||
},
|
||||
})).toHaveResponse({
|
||||
@@ -103,19 +90,6 @@ test('select tab', async ({ client }) => {
|
||||
- generic [active] [ref=e1]: Body one
|
||||
\`\`\``),
|
||||
});
|
||||
|
||||
expect(await client.callTool({
|
||||
name: 'browser_tabs',
|
||||
arguments: {
|
||||
action: 'select',
|
||||
index: 0,
|
||||
},
|
||||
})).toHaveResponse({
|
||||
tabs: `- 0: (current) [] (about:blank)
|
||||
- 1: [Tab one] (data:text/html,<title>Tab one</title><body>Body one</body>)
|
||||
- 2: [Tab two] (data:text/html,<title>Tab two</title><body>Body two</body>)`,
|
||||
pageState: expect.stringContaining(`- Page URL: about:blank`),
|
||||
});
|
||||
});
|
||||
|
||||
test('close tab', async ({ client }) => {
|
||||
@@ -123,9 +97,8 @@ test('close tab', async ({ client }) => {
|
||||
await createTab(client, 'Tab two', 'Body two');
|
||||
|
||||
expect(await client.callTool({
|
||||
name: 'browser_tabs',
|
||||
name: 'browser_tab_close',
|
||||
arguments: {
|
||||
action: 'close',
|
||||
index: 2,
|
||||
},
|
||||
})).toHaveResponse({
|
||||
|
||||
@@ -1,522 +0,0 @@
|
||||
/**
|
||||
* Copyright (c) Microsoft Corporation.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { test, expect } from './fixtures.js';
|
||||
|
||||
test.use({ mcpArgs: ['--caps=verify'] });
|
||||
|
||||
test('browser_verify_element_visible', async ({ client, server }) => {
|
||||
server.setContent('/', `
|
||||
<title>Test Page</title>
|
||||
<button>Submit</button>
|
||||
<h1>Welcome</h1>
|
||||
<div role="alert" aria-label="Success message"></div>
|
||||
`, 'text/html');
|
||||
|
||||
await client.callTool({
|
||||
name: 'browser_navigate',
|
||||
arguments: { url: server.PREFIX },
|
||||
});
|
||||
|
||||
expect(await client.callTool({
|
||||
name: 'browser_verify_element_visible',
|
||||
arguments: {
|
||||
role: 'button',
|
||||
accessibleName: 'Submit',
|
||||
},
|
||||
})).toHaveResponse({
|
||||
result: 'Done',
|
||||
code: `await expect(page.getByRole('button', { name: 'Submit' })).toBeVisible();`,
|
||||
});
|
||||
|
||||
expect(await client.callTool({
|
||||
name: 'browser_verify_element_visible',
|
||||
arguments: {
|
||||
role: 'heading',
|
||||
accessibleName: 'Welcome',
|
||||
},
|
||||
})).toHaveResponse({
|
||||
result: 'Done',
|
||||
code: `await expect(page.getByRole('heading', { name: 'Welcome' })).toBeVisible();`,
|
||||
});
|
||||
|
||||
expect(await client.callTool({
|
||||
name: 'browser_verify_element_visible',
|
||||
arguments: {
|
||||
role: 'alert',
|
||||
accessibleName: 'Success message',
|
||||
},
|
||||
})).toHaveResponse({
|
||||
result: 'Done',
|
||||
code: `await expect(page.getByRole('alert', { name: 'Success message' })).toBeVisible();`,
|
||||
});
|
||||
});
|
||||
|
||||
test('browser_verify_element_visible (not found)', async ({ client, server }) => {
|
||||
server.setContent('/', `
|
||||
<title>Test Page</title>
|
||||
<button>Submit</button>
|
||||
`, 'text/html');
|
||||
|
||||
await client.callTool({
|
||||
name: 'browser_navigate',
|
||||
arguments: { url: server.PREFIX },
|
||||
});
|
||||
|
||||
expect(await client.callTool({
|
||||
name: 'browser_verify_element_visible',
|
||||
arguments: {
|
||||
role: 'button',
|
||||
accessibleName: 'Cancel',
|
||||
},
|
||||
})).toHaveResponse({
|
||||
isError: true,
|
||||
result: 'Element with role "button" and accessible name "Cancel" not found',
|
||||
});
|
||||
});
|
||||
|
||||
test('browser_verify_text_visible', async ({ client, server }) => {
|
||||
server.setContent('/', `
|
||||
<title>Test Page</title>
|
||||
<p>Hello world</p>
|
||||
<div>Welcome to our site</div>
|
||||
<span>Status: Active</span>
|
||||
`, 'text/html');
|
||||
|
||||
await client.callTool({
|
||||
name: 'browser_navigate',
|
||||
arguments: { url: server.PREFIX },
|
||||
});
|
||||
|
||||
expect(await client.callTool({
|
||||
name: 'browser_verify_text_visible',
|
||||
arguments: {
|
||||
text: 'Hello world',
|
||||
},
|
||||
})).toHaveResponse({
|
||||
result: 'Done',
|
||||
code: `await expect(page.getByText('Hello world')).toBeVisible();`,
|
||||
});
|
||||
|
||||
expect(await client.callTool({
|
||||
name: 'browser_verify_text_visible',
|
||||
arguments: {
|
||||
text: 'Welcome to our site',
|
||||
},
|
||||
})).toHaveResponse({
|
||||
result: 'Done',
|
||||
code: `await expect(page.getByText('Welcome to our site')).toBeVisible();`,
|
||||
});
|
||||
|
||||
expect(await client.callTool({
|
||||
name: 'browser_verify_text_visible',
|
||||
arguments: {
|
||||
text: 'Status: Active',
|
||||
},
|
||||
})).toHaveResponse({
|
||||
result: 'Done',
|
||||
code: `await expect(page.getByText('Status: Active')).toBeVisible();`,
|
||||
});
|
||||
});
|
||||
|
||||
test('browser_verify_text_visible (not found)', async ({ client, server }) => {
|
||||
server.setContent('/', `
|
||||
<title>Test Page</title>
|
||||
<p>Hello world</p>
|
||||
`, 'text/html');
|
||||
|
||||
await client.callTool({
|
||||
name: 'browser_navigate',
|
||||
arguments: { url: server.PREFIX },
|
||||
});
|
||||
|
||||
expect(await client.callTool({
|
||||
name: 'browser_verify_text_visible',
|
||||
arguments: {
|
||||
text: 'Goodbye world',
|
||||
},
|
||||
})).toHaveResponse({
|
||||
isError: true,
|
||||
result: 'Text not found',
|
||||
});
|
||||
});
|
||||
|
||||
test('browser_verify_text_visible (with quotes)', async ({ client, server }) => {
|
||||
server.setContent('/', `
|
||||
<title>Test Page</title>
|
||||
<p>She said "Hello world"</p>
|
||||
<div>It's a beautiful day</div>
|
||||
`, 'text/html');
|
||||
|
||||
await client.callTool({
|
||||
name: 'browser_navigate',
|
||||
arguments: { url: server.PREFIX },
|
||||
});
|
||||
|
||||
expect(await client.callTool({
|
||||
name: 'browser_verify_text_visible',
|
||||
arguments: {
|
||||
text: 'She said "Hello world"',
|
||||
},
|
||||
})).toHaveResponse({
|
||||
result: 'Done',
|
||||
code: `await expect(page.getByText('She said "Hello world"')).toBeVisible();`,
|
||||
});
|
||||
|
||||
expect(await client.callTool({
|
||||
name: 'browser_verify_text_visible',
|
||||
arguments: {
|
||||
text: "It's a beautiful day",
|
||||
},
|
||||
})).toHaveResponse({
|
||||
result: 'Done',
|
||||
code: `await expect(page.getByText('It\\'s a beautiful day')).toBeVisible();`,
|
||||
});
|
||||
});
|
||||
|
||||
test('browser_verify_list_visible', async ({ client, server }) => {
|
||||
server.setContent('/', `
|
||||
<title>Test Page</title>
|
||||
<ul>
|
||||
<li>Apple</li>
|
||||
<li>Banana</li>
|
||||
<li>Cherry</li>
|
||||
</ul>
|
||||
`, 'text/html');
|
||||
|
||||
await client.callTool({
|
||||
name: 'browser_navigate',
|
||||
arguments: { url: server.PREFIX },
|
||||
});
|
||||
|
||||
expect(await client.callTool({
|
||||
name: 'browser_verify_list_visible',
|
||||
arguments: {
|
||||
element: 'Fruit list',
|
||||
ref: 'e2',
|
||||
items: ['Apple', 'Banana', 'Cherry'],
|
||||
},
|
||||
})).toHaveResponse({
|
||||
result: 'Done',
|
||||
code: expect.stringContaining(`await expect(page.locator('body')).toMatchAriaSnapshot(\`
|
||||
- list:
|
||||
- listitem: "Apple"
|
||||
- listitem: "Banana"
|
||||
- listitem: "Cherry"
|
||||
\`);`),
|
||||
});
|
||||
});
|
||||
|
||||
test('browser_verify_list_visible (partial items)', async ({ client, server }) => {
|
||||
server.setContent('/', `
|
||||
<title>Test Page</title>
|
||||
<ul>
|
||||
<li>Apple</li>
|
||||
<li>Banana</li>
|
||||
<li>Cherry</li>
|
||||
<li>Date</li>
|
||||
</ul>
|
||||
`, 'text/html');
|
||||
|
||||
await client.callTool({
|
||||
name: 'browser_navigate',
|
||||
arguments: { url: server.PREFIX },
|
||||
});
|
||||
|
||||
expect(await client.callTool({
|
||||
name: 'browser_verify_list_visible',
|
||||
arguments: {
|
||||
element: 'Fruit list',
|
||||
ref: 'e2',
|
||||
items: ['Apple', 'Cherry'],
|
||||
},
|
||||
})).toHaveResponse({
|
||||
result: 'Done',
|
||||
code: expect.stringContaining(`await expect(page.locator('body')).toMatchAriaSnapshot(\`
|
||||
- list:
|
||||
- listitem: "Apple"
|
||||
- listitem: "Cherry"
|
||||
\`);`),
|
||||
});
|
||||
});
|
||||
|
||||
test('browser_verify_list_visible (item not found)', async ({ client, server }) => {
|
||||
server.setContent('/', `
|
||||
<title>Test Page</title>
|
||||
<ul>
|
||||
<li>Apple</li>
|
||||
<li>Banana</li>
|
||||
</ul>
|
||||
`, 'text/html');
|
||||
|
||||
await client.callTool({
|
||||
name: 'browser_navigate',
|
||||
arguments: { url: server.PREFIX },
|
||||
});
|
||||
|
||||
expect(await client.callTool({
|
||||
name: 'browser_verify_list_visible',
|
||||
arguments: {
|
||||
element: 'Fruit list',
|
||||
ref: 'e2',
|
||||
items: ['Apple', 'Cherry'],
|
||||
},
|
||||
})).toHaveResponse({
|
||||
isError: true,
|
||||
result: 'Item "Cherry" not found',
|
||||
});
|
||||
});
|
||||
|
||||
test('browser_verify_value (textbox)', async ({ client, server }) => {
|
||||
server.setContent('/', `
|
||||
<title>Test Page</title>
|
||||
<form>
|
||||
<input type="text" aria-label="Name" value="John Doe" />
|
||||
<input type="email" aria-label="Email" value="john@example.com" />
|
||||
</form>
|
||||
`, 'text/html');
|
||||
|
||||
await client.callTool({
|
||||
name: 'browser_navigate',
|
||||
arguments: { url: server.PREFIX },
|
||||
});
|
||||
|
||||
expect(await client.callTool({
|
||||
name: 'browser_verify_value',
|
||||
arguments: {
|
||||
type: 'textbox',
|
||||
element: 'Name textbox',
|
||||
ref: 'e3',
|
||||
value: 'John Doe',
|
||||
},
|
||||
})).toHaveResponse({
|
||||
result: 'Done',
|
||||
code: expect.stringContaining(`await expect(page.getByRole('textbox', { name: 'Name' })).toHaveValue('John Doe');`),
|
||||
});
|
||||
|
||||
expect(await client.callTool({
|
||||
name: 'browser_verify_value',
|
||||
arguments: {
|
||||
type: 'textbox',
|
||||
element: 'Email textbox',
|
||||
ref: 'e4',
|
||||
value: 'john@example.com',
|
||||
},
|
||||
})).toHaveResponse({
|
||||
result: 'Done',
|
||||
code: expect.stringContaining(`await expect(page.getByRole('textbox', { name: 'Email' })).toHaveValue('john@example.com');`),
|
||||
});
|
||||
});
|
||||
|
||||
test('browser_verify_value (textbox wrong value)', async ({ client, server }) => {
|
||||
server.setContent('/', `
|
||||
<title>Test Page</title>
|
||||
<form>
|
||||
<input type="text" name="name" value="John Doe" />
|
||||
</form>
|
||||
`, 'text/html');
|
||||
|
||||
await client.callTool({
|
||||
name: 'browser_navigate',
|
||||
arguments: { url: server.PREFIX },
|
||||
});
|
||||
|
||||
expect(await client.callTool({
|
||||
name: 'browser_verify_value',
|
||||
arguments: {
|
||||
type: 'textbox',
|
||||
element: 'Name textbox',
|
||||
ref: 'e3',
|
||||
value: 'Jane Smith',
|
||||
},
|
||||
})).toHaveResponse({
|
||||
isError: true,
|
||||
result: 'Expected value "Jane Smith", but got "John Doe"',
|
||||
});
|
||||
});
|
||||
|
||||
test('browser_verify_value (checkbox checked)', async ({ client, server }) => {
|
||||
server.setContent('/', `
|
||||
<title>Test Page</title>
|
||||
<form>
|
||||
<input type="checkbox" name="subscribe" checked />
|
||||
<label for="subscribe">Subscribe to newsletter</label>
|
||||
</form>
|
||||
`, 'text/html');
|
||||
|
||||
await client.callTool({
|
||||
name: 'browser_navigate',
|
||||
arguments: { url: server.PREFIX },
|
||||
});
|
||||
|
||||
expect(await client.callTool({
|
||||
name: 'browser_verify_value',
|
||||
arguments: {
|
||||
type: 'checkbox',
|
||||
element: 'Subscribe checkbox',
|
||||
ref: 'e3',
|
||||
value: 'true',
|
||||
},
|
||||
})).toHaveResponse({
|
||||
result: 'Done',
|
||||
code: expect.stringContaining(`await expect(page.getByRole('checkbox')).toBeChecked();`),
|
||||
});
|
||||
});
|
||||
|
||||
test('browser_verify_value (checkbox unchecked)', async ({ client, server }) => {
|
||||
server.setContent('/', `
|
||||
<title>Test Page</title>
|
||||
<form>
|
||||
<input type="checkbox" name="subscribe" />
|
||||
<label for="subscribe">Subscribe to newsletter</label>
|
||||
</form>
|
||||
`, 'text/html');
|
||||
|
||||
await client.callTool({
|
||||
name: 'browser_navigate',
|
||||
arguments: { url: server.PREFIX },
|
||||
});
|
||||
|
||||
expect(await client.callTool({
|
||||
name: 'browser_verify_value',
|
||||
arguments: {
|
||||
type: 'checkbox',
|
||||
element: 'Subscribe checkbox',
|
||||
ref: 'e3',
|
||||
value: 'false',
|
||||
},
|
||||
})).toHaveResponse({
|
||||
result: 'Done',
|
||||
code: expect.stringContaining(`await expect(page.getByRole('checkbox')).not.toBeChecked();`),
|
||||
});
|
||||
});
|
||||
|
||||
test('browser_verify_value (checkbox wrong value)', async ({ client, server }) => {
|
||||
server.setContent('/', `
|
||||
<title>Test Page</title>
|
||||
<form>
|
||||
<input type="checkbox" name="subscribe" checked />
|
||||
<label for="subscribe">Subscribe to newsletter</label>
|
||||
</form>
|
||||
`, 'text/html');
|
||||
|
||||
await client.callTool({
|
||||
name: 'browser_navigate',
|
||||
arguments: { url: server.PREFIX },
|
||||
});
|
||||
|
||||
expect(await client.callTool({
|
||||
name: 'browser_verify_value',
|
||||
arguments: {
|
||||
type: 'checkbox',
|
||||
element: 'Subscribe checkbox',
|
||||
ref: 'e3',
|
||||
value: 'false',
|
||||
},
|
||||
})).toHaveResponse({
|
||||
isError: true,
|
||||
result: 'Expected value "false", but got "true"',
|
||||
});
|
||||
});
|
||||
|
||||
test('browser_verify_value (radio checked)', async ({ client, server }) => {
|
||||
server.setContent('/', `
|
||||
<title>Test Page</title>
|
||||
<form>
|
||||
<label for="red">Red</label>
|
||||
<input id="red" type="radio" name="color" value="red" checked />
|
||||
<label for="blue">Blue</label>
|
||||
<input id="blue" type="radio" name="color" value="blue" />
|
||||
</form>
|
||||
`, 'text/html');
|
||||
|
||||
await client.callTool({
|
||||
name: 'browser_navigate',
|
||||
arguments: { url: server.PREFIX },
|
||||
});
|
||||
|
||||
expect(await client.callTool({
|
||||
name: 'browser_verify_value',
|
||||
arguments: {
|
||||
type: 'radio',
|
||||
element: 'Color radio',
|
||||
ref: 'e3',
|
||||
value: 'true',
|
||||
},
|
||||
})).toHaveResponse({
|
||||
result: 'Done',
|
||||
code: expect.stringContaining(`await expect(page.getByRole('radio', { name: 'Red' })).toBeChecked();`),
|
||||
});
|
||||
});
|
||||
|
||||
test('browser_verify_value (slider)', async ({ client, server }) => {
|
||||
server.setContent('/', `
|
||||
<title>Test Page</title>
|
||||
<form>
|
||||
<input type="range" name="volume" min="0" max="100" value="75" />
|
||||
<label>Volume</label>
|
||||
</form>
|
||||
`, 'text/html');
|
||||
|
||||
await client.callTool({
|
||||
name: 'browser_navigate',
|
||||
arguments: { url: server.PREFIX },
|
||||
});
|
||||
|
||||
expect(await client.callTool({
|
||||
name: 'browser_verify_value',
|
||||
arguments: {
|
||||
type: 'slider',
|
||||
element: 'Volume slider',
|
||||
ref: 'e3',
|
||||
value: '75',
|
||||
},
|
||||
})).toHaveResponse({
|
||||
result: 'Done',
|
||||
code: expect.stringContaining(`await expect(page.getByRole('slider')).toHaveValue('75');`),
|
||||
});
|
||||
});
|
||||
|
||||
test('browser_verify_value (combobox)', async ({ client, server }) => {
|
||||
server.setContent('/', `
|
||||
<title>Test Page</title>
|
||||
<form>
|
||||
<select name="country">
|
||||
<option>Choose a country</option>
|
||||
<option selected>United States</option>
|
||||
<option>United Kingdom</option>
|
||||
</select>
|
||||
</form>
|
||||
`, 'text/html');
|
||||
|
||||
await client.callTool({
|
||||
name: 'browser_navigate',
|
||||
arguments: { url: server.PREFIX },
|
||||
});
|
||||
|
||||
expect(await client.callTool({
|
||||
name: 'browser_verify_value',
|
||||
arguments: {
|
||||
type: 'combobox',
|
||||
element: 'Country select',
|
||||
ref: 'e3',
|
||||
value: 'United States',
|
||||
},
|
||||
})).toHaveResponse({
|
||||
result: 'Done',
|
||||
code: expect.stringContaining(`await expect(page.getByRole('combobox')).toHaveValue('United States');`),
|
||||
});
|
||||
});
|
||||
@@ -30,7 +30,6 @@ const capabilities = {
|
||||
'core-install': 'Browser installation',
|
||||
'vision': 'Coordinate-based (opt-in via --caps=vision)',
|
||||
'pdf': 'PDF generation (opt-in via --caps=pdf)',
|
||||
'verify': 'Verify (opt-in via --caps=verify)',
|
||||
};
|
||||
|
||||
const toolsByCapability = Object.fromEntries(Object.entries(capabilities).map(([capability, title]) => [title, allTools.filter(tool => tool.capability === capability).sort((a, b) => a.schema.name.localeCompare(b.schema.name))]));
|
||||
|
||||
Reference in New Issue
Block a user