Compare commits
13 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e8e2af40b7 | ||
|
|
b176111891 | ||
|
|
29d468dac7 | ||
|
|
51ab77e04e | ||
|
|
7fb8b0dc3a | ||
|
|
fc04de2be5 | ||
|
|
11480fa8ce | ||
|
|
78298c3448 | ||
|
|
7774ad93ca | ||
|
|
1a64a51812 | ||
|
|
22043cb3ef | ||
|
|
0812df2f5e | ||
|
|
3d1a60b7f3 |
48
.github/workflows/publish-canary.yml
vendored
Normal file
48
.github/workflows/publish-canary.yml
vendored
Normal file
@@ -0,0 +1,48 @@
|
||||
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
|
||||
58
README.md
58
README.md
@@ -80,7 +80,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`. 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@latest`. You can also verify config or add command like arguments via clicking `Edit`.
|
||||
|
||||
</details>
|
||||
|
||||
@@ -494,6 +494,15 @@ 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
|
||||
@@ -696,5 +705,52 @@ 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';
|
||||
export type ToolCapability = 'core' | 'core-tabs' | 'core-install' | 'vision' | 'pdf' | 'verify';
|
||||
|
||||
export type Config = {
|
||||
/**
|
||||
|
||||
@@ -1,26 +1,22 @@
|
||||
{
|
||||
"manifest_version": 3,
|
||||
"name": "Playwright MCP Bridge",
|
||||
"version": "0.0.34",
|
||||
"version": "0.0.36",
|
||||
"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": {
|
||||
@@ -30,7 +26,6 @@
|
||||
"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.34",
|
||||
"version": "0.0.36",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@playwright/mcp-extension",
|
||||
"version": "0.0.34",
|
||||
"version": "0.0.36",
|
||||
"license": "Apache-2.0",
|
||||
"devDependencies": {
|
||||
"@types/chrome": "^0.0.315",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@playwright/mcp-extension",
|
||||
"version": "0.0.34",
|
||||
"version": "0.0.36",
|
||||
"description": "Playwright MCP Browser Extension",
|
||||
"type": "module",
|
||||
"private": true,
|
||||
|
||||
@@ -23,7 +23,9 @@ type Status =
|
||||
| { type: 'connecting'; message: string }
|
||||
| { type: 'connected'; message: string }
|
||||
| { type: 'error'; message: string }
|
||||
| { type: 'error'; versionMismatch: { pwMcpVersion: string; extensionVersion: string; downloadUrl: string } };
|
||||
| { type: 'error'; versionMismatch: { extensionVersion: string; } };
|
||||
|
||||
const SUPPORTED_PROTOCOL_VERSION = 1;
|
||||
|
||||
const ConnectApp: React.FC = () => {
|
||||
const [tabs, setTabs] = useState<TabInfo[]>([]);
|
||||
@@ -59,18 +61,16 @@ const ConnectApp: React.FC = () => {
|
||||
return;
|
||||
}
|
||||
|
||||
const pwMcpVersion = params.get('pwMcpVersion');
|
||||
const extensionVersion = chrome.runtime.getManifest().version;
|
||||
if (pwMcpVersion !== extensionVersion) {
|
||||
const downloadUrl = params.get('downloadUrl') || `https://github.com/microsoft/playwright-mcp/releases/download/v${extensionVersion}/playwright-mcp-extension-v${extensionVersion}.zip`;
|
||||
const parsedVersion = parseInt(params.get('protocolVersion') ?? '', 10);
|
||||
const requiredVersion = isNaN(parsedVersion) ? 1 : parsedVersion;
|
||||
if (requiredVersion > SUPPORTED_PROTOCOL_VERSION) {
|
||||
const extensionVersion = chrome.runtime.getManifest().version;
|
||||
setShowButtons(false);
|
||||
setShowTabList(false);
|
||||
setStatus({
|
||||
type: 'error',
|
||||
versionMismatch: {
|
||||
pwMcpVersion: pwMcpVersion || 'unknown',
|
||||
extensionVersion,
|
||||
downloadUrl
|
||||
}
|
||||
});
|
||||
return;
|
||||
@@ -199,33 +199,13 @@ const ConnectApp: React.FC = () => {
|
||||
);
|
||||
};
|
||||
|
||||
const VersionMismatchError: React.FC<{ pwMcpVersion: string; extensionVersion: string; downloadUrl: string }> = ({ pwMcpVersion, extensionVersion, downloadUrl }) => {
|
||||
const VersionMismatchError: React.FC<{ extensionVersion: string }> = ({ extensionVersion }) => {
|
||||
const readmeUrl = 'https://github.com/microsoft/playwright-mcp/blob/main/extension/README.md';
|
||||
|
||||
const handleDownloadAndOpenExtensions = () => {
|
||||
// Start download
|
||||
const link = document.createElement('a');
|
||||
link.href = downloadUrl;
|
||||
link.download = `playwright-mcp-extension-v${extensionVersion}.zip`;
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
|
||||
setTimeout(() => {
|
||||
chrome.tabs.query({ active: true, currentWindow: true }, tabs => {
|
||||
if (tabs[0]?.id)
|
||||
chrome.tabs.update(tabs[0].id, { url: 'chrome://extensions/' });
|
||||
});
|
||||
}, 1000); // Wait 1 second for download to initiate
|
||||
};
|
||||
|
||||
const latestReleaseUrl = 'https://github.com/microsoft/playwright-mcp/releases/latest';
|
||||
return (
|
||||
<div>
|
||||
Incompatible Playwright MCP version: {pwMcpVersion} (extension version: {extensionVersion}).{' '}
|
||||
<button
|
||||
onClick={handleDownloadAndOpenExtensions}
|
||||
className='link-button'
|
||||
>Click here</button> to download the matching extension, then drag and drop it into the Chrome Extensions page.{' '}
|
||||
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.
|
||||
</div>
|
||||
);
|
||||
@@ -236,9 +216,7 @@ const StatusBanner: React.FC<{ status: Status }> = ({ status }) => {
|
||||
<div className={`status-banner ${status.type}`}>
|
||||
{'versionMismatch' in status ? (
|
||||
<VersionMismatchError
|
||||
pwMcpVersion={status.versionMismatch.pwMcpVersion}
|
||||
extensionVersion={status.versionMismatch.extensionVersion}
|
||||
downloadUrl={status.versionMismatch.downloadUrl}
|
||||
/>
|
||||
) : (
|
||||
status.message
|
||||
|
||||
@@ -18,7 +18,6 @@ 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';
|
||||
@@ -34,6 +33,7 @@ type TestFixtures = {
|
||||
browserWithExtension: BrowserWithExtension,
|
||||
pathToExtension: string,
|
||||
useShortConnectionTimeout: (timeoutMs: number) => void
|
||||
overrideProtocolVersion: (version: number) => void
|
||||
};
|
||||
|
||||
const test = base.extend<TestFixtures>({
|
||||
@@ -81,6 +81,12 @@ 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> {
|
||||
@@ -117,7 +123,7 @@ async function startWithExtensionFlag(browserWithExtension: BrowserWithExtension
|
||||
return client;
|
||||
}
|
||||
|
||||
const testWithOldVersion = test.extend({
|
||||
const testWithOldExtensionVersion = test.extend({
|
||||
pathToExtension: async ({}, use, testInfo) => {
|
||||
const extensionDir = testInfo.outputPath('extension');
|
||||
const oldPath = fileURLToPath(new URL('../dist', import.meta.url));
|
||||
@@ -216,7 +222,7 @@ for (const [mode, startClientMethod] of [
|
||||
await confirmationPagePromise;
|
||||
});
|
||||
|
||||
testWithOldVersion(`extension version mismatch (${mode})`, async ({ browserWithExtension, startClient, server, useShortConnectionTimeout }) => {
|
||||
testWithOldExtensionVersion(`works with old extension version (${mode})`, async ({ browserWithExtension, startClient, server, useShortConnectionTimeout }) => {
|
||||
useShortConnectionTimeout(500);
|
||||
|
||||
// Prelaunch the browser, so that it is properly closed after the test.
|
||||
@@ -233,19 +239,69 @@ 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')).toHaveText(`Incompatible Playwright MCP version: ${packageJSON.version} (extension version: 0.0.1). Click here to download the matching extension, then drag and drop it into the Chrome Extensions page. See installation instructions for more details.`);
|
||||
await expect(confirmationPage.locator('.status-banner')).toContainText(`Playwright MCP version trying to connect requires newer extension version`);
|
||||
|
||||
expect(await navigateResponse).toHaveResponse({
|
||||
result: expect.stringContaining('Extension connection timeout.'),
|
||||
isError: true,
|
||||
});
|
||||
|
||||
const downloadPromise = confirmationPage.waitForEvent('download');
|
||||
await confirmationPage.locator('.status-banner').getByRole('button', { name: 'Click here' }).click();
|
||||
const download = await downloadPromise;
|
||||
expect(download.url()).toBe(`https://github.com/microsoft/playwright-mcp/releases/download/v0.0.1/playwright-mcp-extension-v0.0.1.zip`);
|
||||
await download.cancel();
|
||||
});
|
||||
|
||||
}
|
||||
|
||||
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.34",
|
||||
"version": "0.0.36",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@playwright/mcp",
|
||||
"version": "0.0.34",
|
||||
"version": "0.0.36",
|
||||
"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.55.0-alpha-2025-08-12",
|
||||
"playwright-core": "1.55.0-alpha-2025-08-12",
|
||||
"playwright": "1.56.0-alpha-1756505518000",
|
||||
"playwright-core": "1.56.0-alpha-1756505518000",
|
||||
"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.55.0-alpha-2025-08-12",
|
||||
"@playwright/test": "1.56.0-alpha-1756505518000",
|
||||
"@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.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==",
|
||||
"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==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"playwright": "1.55.0-alpha-2025-08-12"
|
||||
"playwright": "1.56.0-alpha-1756505518000"
|
||||
},
|
||||
"bin": {
|
||||
"playwright": "cli.js"
|
||||
@@ -3745,12 +3745,12 @@
|
||||
}
|
||||
},
|
||||
"node_modules/playwright": {
|
||||
"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==",
|
||||
"version": "1.56.0-alpha-1756505518000",
|
||||
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.56.0-alpha-1756505518000.tgz",
|
||||
"integrity": "sha512-aChIG1Hly/pxzVdwOMArmOMNz4Wo2VyWBxLaMvLJaGWRPPB9+Sl1N8PRm6oH1CbbpFGpPvIeXl83LomkibShRA==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"playwright-core": "1.55.0-alpha-2025-08-12"
|
||||
"playwright-core": "1.56.0-alpha-1756505518000"
|
||||
},
|
||||
"bin": {
|
||||
"playwright": "cli.js"
|
||||
@@ -3763,9 +3763,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/playwright-core": {
|
||||
"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==",
|
||||
"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==",
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
"playwright-core": "cli.js"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@playwright/mcp",
|
||||
"version": "0.0.34",
|
||||
"version": "0.0.36",
|
||||
"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.55.0-alpha-2025-08-12",
|
||||
"playwright-core": "1.55.0-alpha-2025-08-12",
|
||||
"playwright": "1.56.0-alpha-1756505518000",
|
||||
"playwright-core": "1.56.0-alpha-1756505518000",
|
||||
"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.55.0-alpha-2025-08-12",
|
||||
"@playwright/test": "1.56.0-alpha-1756505518000",
|
||||
"@stylistic/eslint-plugin": "^3.0.1",
|
||||
"@types/debug": "^4.1.12",
|
||||
"@types/node": "^22.13.10",
|
||||
|
||||
@@ -29,10 +29,11 @@ import { WebSocket, WebSocketServer } from 'ws';
|
||||
import { httpAddressToString } from '../mcp/http.js';
|
||||
import { logUnhandledError } from '../utils/log.js';
|
||||
import { ManualPromise } from '../mcp/manualPromise.js';
|
||||
import { packageJSON } from '../utils/package.js';
|
||||
import * as protocol from './protocol.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');
|
||||
@@ -59,6 +60,7 @@ export class CDPRelayServer {
|
||||
private _wsHost: string;
|
||||
private _browserChannel: string;
|
||||
private _userDataDir?: string;
|
||||
private _executablePath?: string;
|
||||
private _cdpPath: string;
|
||||
private _extensionPath: string;
|
||||
private _wss: WebSocketServer;
|
||||
@@ -72,10 +74,11 @@ export class CDPRelayServer {
|
||||
private _nextSessionId: number = 1;
|
||||
private _extensionConnectionPromise!: ManualPromise<void>;
|
||||
|
||||
constructor(server: http.Server, browserChannel: string, userDataDir?: string) {
|
||||
constructor(server: http.Server, browserChannel: string, userDataDir?: string, executablePath?: 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}`;
|
||||
@@ -120,16 +123,20 @@ export class CDPRelayServer {
|
||||
version: clientInfo.version,
|
||||
};
|
||||
url.searchParams.set('client', JSON.stringify(client));
|
||||
url.searchParams.set('pwMcpVersion', packageJSON.version);
|
||||
url.searchParams.set('protocolVersion', process.env.PWMCP_TEST_PROTOCOL_VERSION ?? protocol.VERSION.toString());
|
||||
if (toolName)
|
||||
url.searchParams.set('newTab', String(toolName === 'browser_navigate'));
|
||||
const href = url.toString();
|
||||
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.`);
|
||||
|
||||
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 args: string[] = [];
|
||||
if (this._userDataDir)
|
||||
@@ -231,7 +238,7 @@ export class CDPRelayServer {
|
||||
this._extensionConnectionPromise.resolve();
|
||||
}
|
||||
|
||||
private _handleExtensionMessage(method: string, params: any) {
|
||||
private _handleExtensionMessage<M extends keyof ExtensionEvents>(method: M, params: ExtensionEvents[M]['params']) {
|
||||
switch (method) {
|
||||
case 'forwardCDPEvent':
|
||||
const sessionId = params.sessionId || this._connectedTabInfo?.sessionId;
|
||||
@@ -241,10 +248,6 @@ export class CDPRelayServer {
|
||||
params: params.params
|
||||
});
|
||||
break;
|
||||
case 'detachedFromTab':
|
||||
debugLogger('← Debugger detached from tab:', params);
|
||||
this._connectedTabInfo = undefined;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -281,7 +284,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++}`,
|
||||
@@ -335,7 +338,7 @@ class ExtensionConnection {
|
||||
private readonly _callbacks = new Map<number, { resolve: (o: any) => void, reject: (e: Error) => void, error: Error }>();
|
||||
private _lastId = 0;
|
||||
|
||||
onmessage?: (method: string, params: any) => void;
|
||||
onmessage?: <M extends keyof ExtensionEvents>(method: M, params: ExtensionEvents[M]['params']) => void;
|
||||
onclose?: (self: ExtensionConnection, reason: string) => void;
|
||||
|
||||
constructor(ws: WebSocket) {
|
||||
@@ -345,11 +348,11 @@ class ExtensionConnection {
|
||||
this._ws.on('error', this._onError.bind(this));
|
||||
}
|
||||
|
||||
async send(method: string, params?: any, sessionId?: string): Promise<any> {
|
||||
async send<M extends keyof ExtensionCommand>(method: M, params: ExtensionCommand[M]['params']): 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, sessionId }));
|
||||
this._ws.send(JSON.stringify({ id, method, params }));
|
||||
const error = new Error(`Protocol error: ${method}`);
|
||||
return new Promise((resolve, reject) => {
|
||||
this._callbacks.set(id, { resolve, reject, error });
|
||||
@@ -394,7 +397,7 @@ class ExtensionConnection {
|
||||
} else if (object.id) {
|
||||
debugLogger('← Extension: unexpected response', object);
|
||||
} else {
|
||||
this.onmessage?.(object.method!, object.params);
|
||||
this.onmessage?.(object.method! as keyof ExtensionEvents, object.params);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -26,10 +26,12 @@ 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) {
|
||||
constructor(browserChannel: string, userDataDir: string | undefined, executablePath: 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> }> {
|
||||
@@ -55,7 +57,7 @@ export class ExtensionContextFactory implements BrowserContextFactory {
|
||||
httpServer.close();
|
||||
throw new Error(abortSignal.reason);
|
||||
}
|
||||
const cdpRelayServer = new CDPRelayServer(httpServer, this._browserChannel, this._userDataDir);
|
||||
const cdpRelayServer = new CDPRelayServer(httpServer, this._browserChannel, this._userDataDir, this._executablePath);
|
||||
abortSignal.addEventListener('abort', () => cdpRelayServer.stop());
|
||||
debugLogger(`CDP relay server started, extension endpoint: ${cdpRelayServer.extensionEndpoint()}.`);
|
||||
return cdpRelayServer;
|
||||
|
||||
42
src/extension/protocol.ts
Normal file
42
src/extension/protocol.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
/**
|
||||
* 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,
|
||||
};
|
||||
};
|
||||
};
|
||||
@@ -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);
|
||||
const extensionContextFactory = new ExtensionContextFactory(config.browser.launchOptions.channel || 'chrome', config.browser.userDataDir, config.browser.launchOptions.executablePath);
|
||||
|
||||
if (options.extension) {
|
||||
const serverBackendFactory: mcpServer.ServerBackendFactory = {
|
||||
|
||||
114
src/test.ts
114
src/test.ts
@@ -1,114 +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 { Client } from '@modelcontextprotocol/sdk/client/index.js';
|
||||
import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js';
|
||||
import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js';
|
||||
import { ListRootsRequestSchema, PingRequestSchema } from '@modelcontextprotocol/sdk/types.js';
|
||||
|
||||
export async function connectMCP() {
|
||||
// const transport = new StreamableHTTPClientTransport(new URL('http://localhost:4242/mcp'));
|
||||
|
||||
const transport = new StdioClientTransport({
|
||||
command: 'node',
|
||||
env: process.env as any,
|
||||
args: [
|
||||
'/Users/yurys/playwright-mcp/cli.js',
|
||||
// '--browser=chrome-canary',
|
||||
// '--extension'
|
||||
// '--browser=chromium',
|
||||
// '--no-sandbox',
|
||||
// '--isolated',
|
||||
],
|
||||
stderr: 'inherit',
|
||||
});
|
||||
|
||||
|
||||
console.error('will create client');
|
||||
const client = new Client({ name: 'Visual Studio Code', version: '1.0.0' });
|
||||
client.setRequestHandler(PingRequestSchema, async () => ({}));
|
||||
|
||||
console.error('Will connect');
|
||||
try {
|
||||
await client.connect(transport);
|
||||
} catch (error) {
|
||||
console.error('Connection error:', error);
|
||||
}
|
||||
console.error('Connected');
|
||||
|
||||
// const tools = await client.listTools();
|
||||
// console.log('Available tools:', tools.tools.length);
|
||||
|
||||
// await client.ping();
|
||||
// console.error('Pinged');
|
||||
|
||||
{
|
||||
const response = await client.callTool({
|
||||
name: 'browser_navigate',
|
||||
arguments: {
|
||||
url: 'https://amazon.com/'
|
||||
}
|
||||
});
|
||||
console.log('Navigated to Amazon', response.isError ? 'error' : '', response.error ? response.error : '');
|
||||
}
|
||||
|
||||
// const r = await client.callTool({
|
||||
// name: 'browser_connect',
|
||||
// arguments: {
|
||||
// name: 'extension'
|
||||
// }
|
||||
// });
|
||||
// console.log('Connected to extension', r.isError ? 'error' : '', r.content);
|
||||
|
||||
const response = await client.callTool({
|
||||
name: 'browser_navigate',
|
||||
arguments: {
|
||||
url: 'https://google.com/'
|
||||
}
|
||||
});
|
||||
console.log('Navigated to Google', response.isError ? 'error' : '', response.isError ? response : '');
|
||||
|
||||
if (response.isError)
|
||||
return;
|
||||
|
||||
const response2 = await client.callTool({
|
||||
name: 'browser_type',
|
||||
arguments: {
|
||||
text: 'Browser MCP',
|
||||
submit: true,
|
||||
element: 'combobox "Search" [active] [ref=e44]',
|
||||
ref: 'e44',
|
||||
}
|
||||
});
|
||||
console.log('Typed text', response2.isError ? response2.content : '');
|
||||
|
||||
// console.log('Closing browser...');
|
||||
// const response3 = await client.callTool({
|
||||
// name: 'browser_close',
|
||||
// arguments: {}
|
||||
// });
|
||||
// console.log('Closed browser');
|
||||
// console.log(response3.isError ? 'error' : '', response3.error ? response3.error : '');
|
||||
|
||||
|
||||
// await new Promise(resolve => setTimeout(resolve, 5_000));
|
||||
|
||||
// await transport.terminateSession();
|
||||
await client.close();
|
||||
console.log('Closed MCP client');
|
||||
}
|
||||
|
||||
void connectMCP();
|
||||
@@ -19,8 +19,10 @@ 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';
|
||||
@@ -28,7 +30,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 mouse from './tools/mouse.js';
|
||||
import verify from './tools/verify.js';
|
||||
|
||||
import type { Tool } from './tools/tool.js';
|
||||
import type { FullConfig } from './config.js';
|
||||
@@ -39,6 +41,7 @@ export const allTools: Tool<any>[] = [
|
||||
...dialogs,
|
||||
...evaluate,
|
||||
...files,
|
||||
...form,
|
||||
...install,
|
||||
...keyboard,
|
||||
...navigate,
|
||||
@@ -49,6 +52,7 @@ export const allTools: Tool<any>[] = [
|
||||
...snapshot,
|
||||
...tabs,
|
||||
...wait,
|
||||
...verify,
|
||||
];
|
||||
|
||||
export function filteredTools(config: FullConfig) {
|
||||
|
||||
61
src/tools/form.ts
Normal file
61
src/tools/form.ts
Normal file
@@ -0,0 +1,61 @@
|
||||
/**
|
||||
* 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,
|
||||
];
|
||||
@@ -49,7 +49,7 @@ const browserTabs = defineTool({
|
||||
return;
|
||||
}
|
||||
case 'select': {
|
||||
if (!params.index)
|
||||
if (params.index === undefined)
|
||||
throw new Error('Tab index is required');
|
||||
await context.selectTab(params.index);
|
||||
response.setIncludeSnapshot();
|
||||
|
||||
149
src/tools/verify.ts
Normal file
149
src/tools/verify.ts
Normal file
@@ -0,0 +1,149 @@
|
||||
/**
|
||||
* 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,
|
||||
];
|
||||
@@ -24,6 +24,7 @@ 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',
|
||||
@@ -54,6 +55,7 @@ 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',
|
||||
|
||||
@@ -57,6 +57,25 @@ 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,6 +31,7 @@ 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;
|
||||
};
|
||||
@@ -65,17 +66,19 @@ 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 }, use, testInfo) => {
|
||||
startClient: async ({ mcpHeadless, mcpBrowser, mcpMode, mcpArgs }, use, testInfo) => {
|
||||
const configDir = path.dirname(test.info().config.configFile!);
|
||||
const clients: Client[] = [];
|
||||
|
||||
await use(async options => {
|
||||
const args: string[] = [];
|
||||
const args: string[] = mcpArgs ?? [];
|
||||
if (process.env.CI && process.platform === 'linux')
|
||||
args.push('--no-sandbox');
|
||||
if (mcpHeadless)
|
||||
|
||||
123
tests/form.spec.ts
Normal file
123
tests/form.spec.ts
Normal file
@@ -0,0 +1,123 @@
|
||||
/**
|
||||
* 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]'),
|
||||
});
|
||||
});
|
||||
@@ -103,6 +103,19 @@ 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 }) => {
|
||||
|
||||
522
tests/verify.spec.ts
Normal file
522
tests/verify.spec.ts
Normal file
@@ -0,0 +1,522 @@
|
||||
/**
|
||||
* 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,6 +30,7 @@ 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