18 Commits

Author SHA1 Message Date
copilot-swe-agent[bot]
41ba5ba0eb Merge remote-tracking branch 'origin/main' into copilot/fix-759
# Conflicts:
#	src/browserContextFactory.ts
2025-08-02 01:27:58 +00:00
Pavel Feldman
3c6eac9b21 chore: follow up with win test fix (#818) 2025-08-01 18:19:03 -07:00
Yury Semikhatsky
41a44f7abc chore(extension): terminate connection on debugger detach (#816) 2025-08-01 17:56:47 -07:00
Yury Semikhatsky
372395666a chore: allow to switch between browser connection methods (#815) 2025-08-01 17:34:28 -07:00
Pavel Feldman
a60d7b8cd1 chore: slice profile dirs by root in vscode (#814) 2025-08-01 16:59:59 -07:00
copilot-swe-agent[bot]
d4667f865d Address review feedback: move permissions to contextOptions, remove redundant tests and files
Co-authored-by: pavelfeldman <883973+pavelfeldman@users.noreply.github.com>
2025-08-01 23:59:38 +00:00
Pavel Feldman
ffe0117456 chore: refactor initialize (#812) 2025-08-01 13:06:36 -07:00
copilot-swe-agent[bot]
ce81f556a5 Implement clipboard permissions support feature
Co-authored-by: pavelfeldman <883973+pavelfeldman@users.noreply.github.com>
2025-08-01 18:56:45 +00:00
copilot-swe-agent[bot]
fc127d5895 Initial plan 2025-08-01 18:32:15 +00:00
Yury Semikhatsky
7c07cc86eb chore(extension): bind relay lifetime to browser context (#804) 2025-07-31 22:25:40 -07:00
Pavel Feldman
3787439fc1 chore: serialize session entries for tool calls and user actions (#803) 2025-07-31 15:16:56 -07:00
Max Schmitt
2a86ac74e3 chore: use pngs by default for screenshots (#797)
1. Use PNG by default.
1. Increase JPG quality from `50` -> `90`.
2025-07-31 11:03:19 +02:00
Pavel Feldman
6dd44923da chore: make tab snapshot structured to mimic it in recorder (#799) 2025-07-30 20:57:34 -07:00
Pavel Feldman
f600234897 chore: record user actions in the session log (#798) 2025-07-30 18:26:13 -07:00
Pavel Feldman
4df162aff5 chore: parse response in tests (#796) 2025-07-30 12:47:22 -07:00
Yury Semikhatsky
65d99fe595 chore(extension): do not show chrome: tabs (#780) 2025-07-29 10:11:44 -07:00
Yury Semikhatsky
903c857f19 chore(extension): use separate package.json (#778) 2025-07-28 17:16:08 -07:00
Yury Semikhatsky
9b5f97b076 chore(extension): use react for connect dialog (#777) 2025-07-28 15:23:33 -07:00
66 changed files with 4464 additions and 1023 deletions

View File

@@ -169,6 +169,8 @@ Playwright MCP server supports following arguments. They can be provided in the
--no-sandbox disable the sandbox for all process types that
are normally sandboxed.
--output-dir <path> path to the directory for output files.
--permissions <permissions> comma-separated list of permissions to grant, for
example "clipboard-read,clipboard-write"
--port <port> port to listen on for SSE transport.
--proxy-bypass <bypass> comma-separated domains to bypass proxy, for
example ".com,chromium.org,.domain.com"
@@ -544,7 +546,7 @@ http.createServer(async (req, res) => {
- Title: Take a screenshot
- Description: Take a screenshot of the current page. You can't perform actions based on the screenshot, use browser_snapshot for actions.
- Parameters:
- `raw` (boolean, optional): Whether to return without compression (in PNG format). Default is false, which returns a JPEG image.
- `type` (string, optional): Image format for the screenshot. Default is png.
- `filename` (string, optional): File name to save the screenshot to. Defaults to `page-{timestamp}.{png|jpeg}` if not specified.
- `element` (string, optional): Human-readable element description used to obtain permission to screenshot the element. If not provided, the screenshot will be taken of viewport. If element is provided, ref must be provided too.
- `ref` (string, optional): Exact target element reference from the page snapshot. If not provided, the screenshot will be taken of viewport. If ref is provided, element must be provided too.

View File

@@ -17,18 +17,11 @@
<html>
<head>
<title>Playwright MCP extension</title>
<meta name="viewport" content="width=device-width, initial-scale=1">
<link rel="stylesheet" href="src/ui/connect.css">
</head>
<body>
<h3>Playwright MCP extension</h3>
<div id="status-container"></div>
<div class="button-row">
<button id="continue-btn">Continue</button>
<button id="reject-btn">Reject</button>
</div>
<div id="tab-list-container">
<h4>Select page to expose to MCP server:</h4>
<div id="tab-list"></div>
</div>
<script src="lib/connect.js"></script>
<div id="root"></div>
<script type="module" src="src/ui/connect.tsx"></script>
</body>
</html>

1574
extension/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

34
extension/package.json Normal file
View File

@@ -0,0 +1,34 @@
{
"name": "@playwright/mcp-extension",
"version": "0.0.32",
"description": "Playwright MCP Browser Extension",
"type": "module",
"private": true,
"repository": {
"type": "git",
"url": "git+https://github.com/microsoft/playwright-mcp.git"
},
"homepage": "https://playwright.dev",
"engines": {
"node": ">=18"
},
"author": {
"name": "Microsoft Corporation"
},
"license": "Apache-2.0",
"scripts": {
"build": "tsc --project . && tsc --project tsconfig.ui.json && vite build",
"watch": "tsc --watch --project . & tsc --watch --project tsconfig.ui.json & vite build --watch",
"clean": "rm -rf lib"
},
"devDependencies": {
"@types/chrome": "^0.0.315",
"@types/react": "^18.2.66",
"@types/react-dom": "^18.2.22",
"@vitejs/plugin-react": "^4.0.0",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"typescript": "^5.8.2",
"vite": "^5.0.0"
}
}

View File

@@ -117,7 +117,7 @@ class TabShareExtension {
private async _getTabs(): Promise<chrome.tabs.Tab[]> {
const tabs = await chrome.tabs.query({});
return tabs;
return tabs.filter(tab => tab.url && !['chrome:', 'edge:', 'devtools:'].some(scheme => tab.url!.startsWith(scheme)));
}
}

View File

@@ -1,172 +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.
*/
interface TabInfo {
id: number;
windowId: number;
title: string;
url: string;
favIconUrl?: string;
}
class ConnectPage {
private _tabList: HTMLElement;
private _tabListContainer: HTMLElement;
private _statusContainer: HTMLElement;
private _selectedTab: TabInfo | undefined;
constructor() {
this._tabList = document.getElementById('tab-list')!;
this._tabListContainer = document.getElementById('tab-list-container')!;
this._statusContainer = document.getElementById('status-container') as HTMLElement;
this._addButtonHandlers();
void this._loadTabs();
}
private _addButtonHandlers() {
const continueBtn = document.getElementById('continue-btn') as HTMLButtonElement;
const rejectBtn = document.getElementById('reject-btn') as HTMLButtonElement;
const buttonRow = document.querySelector('.button-row') as HTMLElement;
const params = new URLSearchParams(window.location.search);
const mcpRelayUrl = params.get('mcpRelayUrl');
if (!mcpRelayUrl) {
buttonRow.style.display = 'none';
this._showStatus('error', 'Missing mcpRelayUrl parameter in URL.');
return;
}
let clientInfo = 'unknown';
try {
const client = JSON.parse(params.get('client') || '{}');
clientInfo = `${client.name}/${client.version}`;
} catch (e) {
this._showStatus('error', 'Failed to parse client version.');
return;
}
this._showStatus('connecting', `MCP client "${clientInfo}" is trying to connect. Do you want to continue?`);
rejectBtn.addEventListener('click', async () => {
buttonRow.style.display = 'none';
this._tabListContainer.style.display = 'none';
this._showStatus('error', 'Connection rejected. This tab can be closed.');
});
continueBtn.addEventListener('click', async () => {
buttonRow.style.display = 'none';
this._tabListContainer.style.display = 'none';
try {
const selectedTab = this._selectedTab;
if (!selectedTab) {
this._showStatus('error', 'Tab not selected.');
return;
}
const response = await chrome.runtime.sendMessage({
type: 'connectToMCPRelay',
mcpRelayUrl,
tabId: selectedTab.id,
windowId: selectedTab.windowId,
});
if (response?.success)
this._showStatus('connected', `MCP client "${clientInfo}" connected.`);
else
this._showStatus('error', response?.error || `MCP client "${clientInfo}" failed to connect.`);
} catch (e) {
this._showStatus('error', `MCP client "${clientInfo}" failed to connect: ${e}`);
}
});
}
private async _loadTabs(): Promise<void> {
try {
const response = await chrome.runtime.sendMessage({ type: 'getTabs' });
if (response.success)
this._populateTabList(response.tabs, response.currentTabId);
else
this._showStatus('error', 'Failed to load tabs: ' + response.error);
} catch (error) {
this._showStatus('error', 'Failed to communicate with background script: ' + error);
}
}
private _populateTabList(tabs: TabInfo[], currentTabId: number): void {
this._tabList.replaceChildren();
this._selectedTab = tabs.find(tab => tab.id === currentTabId);
tabs.forEach((tab, index) => {
const tabElement = this._createTabElement(tab);
this._tabList.appendChild(tabElement);
});
}
private _createTabElement(tab: TabInfo): HTMLElement {
const disabled = tab.url.startsWith('chrome://');
const tabInfoDiv = document.createElement('div');
tabInfoDiv.className = 'tab-info';
tabInfoDiv.style.padding = '5px';
if (disabled)
tabInfoDiv.style.opacity = '0.5';
const radioButton = document.createElement('input');
radioButton.type = 'radio';
radioButton.name = 'tab-selection';
radioButton.checked = tab.id === this._selectedTab?.id;
radioButton.id = `tab-${tab.id}`;
radioButton.addEventListener('change', e => {
if (radioButton.checked)
this._selectedTab = tab;
});
if (disabled)
radioButton.disabled = true;
const favicon = document.createElement('img');
favicon.className = 'tab-favicon';
if (tab.favIconUrl)
favicon.src = tab.favIconUrl;
favicon.alt = '';
favicon.style.height = '16px';
favicon.style.width = '16px';
const title = document.createElement('span');
title.style.paddingLeft = '5px';
title.className = 'tab-title';
title.textContent = tab.title || 'Untitled';
const url = document.createElement('span');
url.style.paddingLeft = '5px';
url.className = 'tab-url';
url.textContent = tab.url;
tabInfoDiv.appendChild(radioButton);
tabInfoDiv.appendChild(favicon);
tabInfoDiv.appendChild(title);
tabInfoDiv.appendChild(url);
return tabInfoDiv;
}
private _showStatus(type: 'connected' | 'error' | 'connecting', message: string) {
const div = document.createElement('div');
div.className = `status ${type}`;
div.textContent = message;
this._statusContainer.replaceChildren(div);
}
}
new ConnectPage();

View File

@@ -59,10 +59,6 @@ export class RelayConnection {
this._ws.close(1000, message);
}
private async _detachDebugger(): Promise<void> {
await chrome.debugger.detach(this._debuggee);
}
private _onDebuggerEvent(source: chrome.debugger.DebuggerSession, method: string, params: any): void {
if (source.tabId !== this._debuggee.tabId)
return;
@@ -81,13 +77,7 @@ export class RelayConnection {
private _onDebuggerDetach(source: chrome.debugger.Debuggee, reason: string): void {
if (source.tabId !== this._debuggee.tabId)
return;
this._sendMessage({
method: 'detachedFromTab',
params: {
tabId: this._debuggee.tabId,
reason,
},
});
this.close(`Debugger detached: ${reason}`);
this._debuggee = { };
}
@@ -131,10 +121,6 @@ export class RelayConnection {
targetInfo: result?.targetInfo,
};
}
if (message.method === 'detachFromTab') {
debugLog('Detaching debugger from tab:', this._debuggee);
return await this._detachDebugger();
}
if (message.method === 'forwardCDPCommand') {
const { sessionId, method, params } = message.params;
debugLog('CDP command:', method, params);

View File

@@ -0,0 +1,174 @@
/*
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.
*/
body {
margin: 0;
padding: 0;
}
/* Base styles */
.app-container {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "Noto Sans", Helvetica, Arial, sans-serif;
background-color: #ffffff;
color: #1f2328;
margin: 0;
padding: 24px;
min-height: 100vh;
font-size: 14px;
line-height: 1.5;
}
.content-wrapper {
max-width: 600px;
margin: 0 auto;
}
.main-title {
font-size: 32px;
font-weight: 600;
margin-bottom: 8px;
color: #1f2328;
}
/* Status Banner */
.status-banner {
padding: 16px;
margin-bottom: 24px;
border-radius: 6px;
border: 1px solid;
font-size: 14px;
font-weight: 500;
}
.status-banner.connected {
background-color: #dafbe1;
border-color: #1a7f37;
color: #0d5a23;
}
.status-banner.error {
background-color: #ffebe9;
border-color: #da3633;
color: #a40e26;
}
.status-banner.connecting {
background-color: #fff8c5;
border-color: #d1b500;
color: #7a5c00;
}
/* Buttons */
.button-container {
margin-bottom: 24px;
}
.button {
padding: 8px 16px;
border-radius: 6px;
border: 1px solid;
font-size: 14px;
font-weight: 500;
cursor: pointer;
display: inline-flex;
align-items: center;
justify-content: center;
text-decoration: none;
margin-right: 8px;
}
.button.primary {
background-color: #2da44e;
border-color: #2da44e;
color: #ffffff;
}
.button.primary:hover {
background-color: #2c974b;
}
.button.default {
background-color: #f6f8fa;
border-color: #d1d9e0;
color: #24292f;
}
.button.default:hover {
background-color: #f3f4f6;
border-color: #c7d2da;
}
/* Tab selection */
.tab-section-title {
font-size: 20px;
font-weight: 600;
margin-bottom: 16px;
color: #1f2328;
}
.tab-item {
display: flex;
align-items: center;
padding: 12px;
border: 1px solid #d1d9e0;
border-radius: 6px;
margin-bottom: 8px;
background-color: #ffffff;
cursor: pointer;
}
.tab-item.selected {
background-color: #f6f8fa;
}
.tab-item.disabled {
cursor: not-allowed;
opacity: 0.5;
}
.tab-radio {
margin-right: 12px;
flex-shrink: 0;
}
.tab-favicon {
width: 16px;
height: 16px;
margin-right: 8px;
flex-shrink: 0;
}
.tab-content {
flex: 1;
min-width: 0;
}
.tab-title {
font-weight: 500;
color: #1f2328;
margin-bottom: 2px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.tab-url {
font-size: 12px;
color: #656d76;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}

View File

@@ -0,0 +1,209 @@
/**
* 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 React, { useState, useEffect, useCallback } from 'react';
import { createRoot } from 'react-dom/client';
interface TabInfo {
id: number;
windowId: number;
title: string;
url: string;
favIconUrl?: string;
}
type StatusType = 'connected' | 'error' | 'connecting';
const ConnectApp: React.FC = () => {
const [tabs, setTabs] = useState<TabInfo[]>([]);
const [selectedTab, setSelectedTab] = useState<TabInfo | undefined>();
const [status, setStatus] = useState<{ type: StatusType; message: string } | null>(null);
const [showButtons, setShowButtons] = useState(true);
const [showTabList, setShowTabList] = useState(true);
const [clientInfo, setClientInfo] = useState('unknown');
const [mcpRelayUrl, setMcpRelayUrl] = useState('');
useEffect(() => {
const params = new URLSearchParams(window.location.search);
const relayUrl = params.get('mcpRelayUrl');
if (!relayUrl) {
setShowButtons(false);
setStatus({ type: 'error', message: 'Missing mcpRelayUrl parameter in URL.' });
return;
}
setMcpRelayUrl(relayUrl);
try {
const client = JSON.parse(params.get('client') || '{}');
const info = `${client.name}/${client.version}`;
setClientInfo(info);
setStatus({
type: 'connecting',
message: `MCP client "${info}" is trying to connect. Do you want to continue?`
});
} catch (e) {
setStatus({ type: 'error', message: 'Failed to parse client version.' });
return;
}
void loadTabs();
}, []);
const loadTabs = useCallback(async () => {
const response = await chrome.runtime.sendMessage({ type: 'getTabs' });
if (response.success) {
setTabs(response.tabs);
const currentTab = response.tabs.find((tab: TabInfo) => tab.id === response.currentTabId);
setSelectedTab(currentTab);
} else {
setStatus({ type: 'error', message: 'Failed to load tabs: ' + response.error });
}
}, []);
const handleContinue = useCallback(async () => {
setShowButtons(false);
setShowTabList(false);
if (!selectedTab) {
setStatus({ type: 'error', message: 'Tab not selected.' });
return;
}
try {
const response = await chrome.runtime.sendMessage({
type: 'connectToMCPRelay',
mcpRelayUrl,
tabId: selectedTab.id,
windowId: selectedTab.windowId,
});
if (response?.success) {
setStatus({ type: 'connected', message: `MCP client "${clientInfo}" connected.` });
} else {
setStatus({
type: 'error',
message: response?.error || `MCP client "${clientInfo}" failed to connect.`
});
}
} catch (e) {
setStatus({
type: 'error',
message: `MCP client "${clientInfo}" failed to connect: ${e}`
});
}
}, [selectedTab, clientInfo, mcpRelayUrl]);
const handleReject = useCallback(() => {
setShowButtons(false);
setShowTabList(false);
setStatus({ type: 'error', message: 'Connection rejected. This tab can be closed.' });
}, []);
return (
<div className='app-container'>
<div className='content-wrapper'>
<h1 className='main-title'>
Playwright MCP Extension
</h1>
{status && <StatusBanner type={status.type} message={status.message} />}
{showButtons && (
<div className='button-container'>
<Button variant='primary' onClick={handleContinue}>
Continue
</Button>
<Button variant='default' onClick={handleReject}>
Reject
</Button>
</div>
)}
{showTabList && (
<div>
<h2 className='tab-section-title'>
Select page to expose to MCP server:
</h2>
<div>
{tabs.map(tab => (
<TabItem
key={tab.id}
tab={tab}
isSelected={selectedTab?.id === tab.id}
onSelect={() => setSelectedTab(tab)}
/>
))}
</div>
</div>
)}
</div>
</div>
);
};
const StatusBanner: React.FC<{ type: StatusType; message: string }> = ({ type, message }) => {
return <div className={`status-banner ${type}`}>{message}</div>;
};
const Button: React.FC<{ variant: 'primary' | 'default'; onClick: () => void; children: React.ReactNode }> = ({
variant,
onClick,
children
}) => {
return (
<button className={`button ${variant}`} onClick={onClick}>
{children}
</button>
);
};
const TabItem: React.FC<{ tab: TabInfo; isSelected: boolean; onSelect: () => void }> = ({
tab,
isSelected,
onSelect
}) => {
const className = `tab-item ${isSelected ? 'selected' : ''}`.trim();
return (
<div className={className} onClick={onSelect}>
<input
type='radio'
className='tab-radio'
checked={isSelected}
/>
<img
src={tab.favIconUrl || 'data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16"><rect width="16" height="16" fill="%23f6f8fa"/></svg>'}
alt=''
className='tab-favicon'
/>
<div className='tab-content'>
<div className='tab-title'>{tab.title || 'Untitled'}</div>
<div className='tab-url'>{tab.url}</div>
</div>
</div>
);
};
// Initialize the React app
const container = document.getElementById('root');
if (container) {
const root = createRoot(container);
root.render(<ConnectApp />);
}

View File

@@ -0,0 +1,4 @@
// Help VSCode to find right tsconfig file.
{
"extends": "../../tsconfig.ui.json"
}

View File

@@ -8,8 +8,13 @@
"rootDir": "src",
"outDir": "./lib",
"resolveJsonModule": true,
"jsx": "react-jsx",
"jsxImportSource": "react"
},
"include": [
"src",
],
"exclude": [
"src/ui",
]
}

View File

@@ -0,0 +1,19 @@
{
"compilerOptions": {
"target": "ESNext",
"esModuleInterop": true,
"moduleResolution": "node",
"strict": true,
"module": "ESNext",
"rootDir": "src",
"outDir": "./lib",
"resolveJsonModule": true,
"types": ["chrome"],
"jsx": "react-jsx",
"jsxImportSource": "react",
"noEmit": true,
},
"include": [
"src/ui",
],
}

40
extension/vite.config.ts Normal file
View File

@@ -0,0 +1,40 @@
/**
* 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 { resolve } from 'path';
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
// https://vitejs.dev/config/
export default defineConfig({
plugins: [react()],
base: '/lib/ui/',
build: {
outDir: resolve(__dirname, 'lib/ui'),
emptyOutDir: true,
minify: false,
rollupOptions: {
input: resolve(__dirname, 'connect.html'),
output: {
manualChunks: undefined,
inlineDynamicImports: true,
entryFileNames: '[name].js',
chunkFileNames: '[name].js',
assetFileNames: '[name].[ext]'
}
}
}
});

502
package-lock.json generated
View File

@@ -14,9 +14,10 @@
"debug": "^4.4.1",
"dotenv": "^17.2.0",
"mime": "^4.0.7",
"playwright": "1.55.0-alpha-1752701791000",
"playwright-core": "1.55.0-alpha-1752701791000",
"playwright": "1.55.0-alpha-1753913825000",
"playwright-core": "1.55.0-alpha-1753913825000",
"ws": "^8.18.1",
"zod": "^3.24.1",
"zod-to-json-schema": "^3.24.4"
},
"bin": {
@@ -26,15 +27,15 @@
"@anthropic-ai/sdk": "^0.57.0",
"@eslint/eslintrc": "^3.2.0",
"@eslint/js": "^9.19.0",
"@playwright/test": "1.55.0-alpha-1752701791000",
"@playwright/test": "1.55.0-alpha-1753913825000",
"@stylistic/eslint-plugin": "^3.0.1",
"@types/chrome": "^0.0.315",
"@types/debug": "^4.1.12",
"@types/node": "^22.13.10",
"@types/ws": "^8.18.1",
"@typescript-eslint/eslint-plugin": "^8.26.1",
"@typescript-eslint/parser": "^8.26.1",
"@typescript-eslint/utils": "^8.26.1",
"esbuild": "^0.20.1",
"eslint": "^9.19.0",
"eslint-plugin-import": "^2.31.0",
"eslint-plugin-notice": "^1.0.0",
@@ -55,6 +56,397 @@
"anthropic-ai-sdk": "bin/cli"
}
},
"node_modules/@esbuild/aix-ppc64": {
"version": "0.20.2",
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.20.2.tgz",
"integrity": "sha512-D+EBOJHXdNZcLJRBkhENNG8Wji2kgc9AZ9KiPr1JuZjsNtyHzrsfLRrY0tk2H2aoFu6RANO1y1iPPUCDYWkb5g==",
"cpu": [
"ppc64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"aix"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild/android-arm": {
"version": "0.20.2",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.20.2.tgz",
"integrity": "sha512-t98Ra6pw2VaDhqNWO2Oph2LXbz/EJcnLmKLGBJwEwXX/JAN83Fym1rU8l0JUWK6HkIbWONCSSatf4sf2NBRx/w==",
"cpu": [
"arm"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild/android-arm64": {
"version": "0.20.2",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.20.2.tgz",
"integrity": "sha512-mRzjLacRtl/tWU0SvD8lUEwb61yP9cqQo6noDZP/O8VkwafSYwZ4yWy24kan8jE/IMERpYncRt2dw438LP3Xmg==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild/android-x64": {
"version": "0.20.2",
"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.20.2.tgz",
"integrity": "sha512-btzExgV+/lMGDDa194CcUQm53ncxzeBrWJcncOBxuC6ndBkKxnHdFJn86mCIgTELsooUmwUm9FkhSp5HYu00Rg==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild/darwin-arm64": {
"version": "0.20.2",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.20.2.tgz",
"integrity": "sha512-4J6IRT+10J3aJH3l1yzEg9y3wkTDgDk7TSDFX+wKFiWjqWp/iCfLIYzGyasx9l0SAFPT1HwSCR+0w/h1ES/MjA==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild/darwin-x64": {
"version": "0.20.2",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.20.2.tgz",
"integrity": "sha512-tBcXp9KNphnNH0dfhv8KYkZhjc+H3XBkF5DKtswJblV7KlT9EI2+jeA8DgBjp908WEuYll6pF+UStUCfEpdysA==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild/freebsd-arm64": {
"version": "0.20.2",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.20.2.tgz",
"integrity": "sha512-d3qI41G4SuLiCGCFGUrKsSeTXyWG6yem1KcGZVS+3FYlYhtNoNgYrWcvkOoaqMhwXSMrZRl69ArHsGJ9mYdbbw==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"freebsd"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild/freebsd-x64": {
"version": "0.20.2",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.20.2.tgz",
"integrity": "sha512-d+DipyvHRuqEeM5zDivKV1KuXn9WeRX6vqSqIDgwIfPQtwMP4jaDsQsDncjTDDsExT4lR/91OLjRo8bmC1e+Cw==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"freebsd"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild/linux-arm": {
"version": "0.20.2",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.20.2.tgz",
"integrity": "sha512-VhLPeR8HTMPccbuWWcEUD1Az68TqaTYyj6nfE4QByZIQEQVWBB8vup8PpR7y1QHL3CpcF6xd5WVBU/+SBEvGTg==",
"cpu": [
"arm"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild/linux-arm64": {
"version": "0.20.2",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.20.2.tgz",
"integrity": "sha512-9pb6rBjGvTFNira2FLIWqDk/uaf42sSyLE8j1rnUpuzsODBq7FvpwHYZxQ/It/8b+QOS1RYfqgGFNLRI+qlq2A==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild/linux-ia32": {
"version": "0.20.2",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.20.2.tgz",
"integrity": "sha512-o10utieEkNPFDZFQm9CoP7Tvb33UutoJqg3qKf1PWVeeJhJw0Q347PxMvBgVVFgouYLGIhFYG0UGdBumROyiig==",
"cpu": [
"ia32"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild/linux-loong64": {
"version": "0.20.2",
"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.20.2.tgz",
"integrity": "sha512-PR7sp6R/UC4CFVomVINKJ80pMFlfDfMQMYynX7t1tNTeivQ6XdX5r2XovMmha/VjR1YN/HgHWsVcTRIMkymrgQ==",
"cpu": [
"loong64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild/linux-mips64el": {
"version": "0.20.2",
"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.20.2.tgz",
"integrity": "sha512-4BlTqeutE/KnOiTG5Y6Sb/Hw6hsBOZapOVF6njAESHInhlQAghVVZL1ZpIctBOoTFbQyGW+LsVYZ8lSSB3wkjA==",
"cpu": [
"mips64el"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild/linux-ppc64": {
"version": "0.20.2",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.20.2.tgz",
"integrity": "sha512-rD3KsaDprDcfajSKdn25ooz5J5/fWBylaaXkuotBDGnMnDP1Uv5DLAN/45qfnf3JDYyJv/ytGHQaziHUdyzaAg==",
"cpu": [
"ppc64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild/linux-riscv64": {
"version": "0.20.2",
"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.20.2.tgz",
"integrity": "sha512-snwmBKacKmwTMmhLlz/3aH1Q9T8v45bKYGE3j26TsaOVtjIag4wLfWSiZykXzXuE1kbCE+zJRmwp+ZbIHinnVg==",
"cpu": [
"riscv64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild/linux-s390x": {
"version": "0.20.2",
"resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.20.2.tgz",
"integrity": "sha512-wcWISOobRWNm3cezm5HOZcYz1sKoHLd8VL1dl309DiixxVFoFe/o8HnwuIwn6sXre88Nwj+VwZUvJf4AFxkyrQ==",
"cpu": [
"s390x"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild/linux-x64": {
"version": "0.20.2",
"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.20.2.tgz",
"integrity": "sha512-1MdwI6OOTsfQfek8sLwgyjOXAu+wKhLEoaOLTjbijk6E2WONYpH9ZU2mNtR+lZ2B4uwr+usqGuVfFT9tMtGvGw==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild/netbsd-x64": {
"version": "0.20.2",
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.20.2.tgz",
"integrity": "sha512-K8/DhBxcVQkzYc43yJXDSyjlFeHQJBiowJ0uVL6Tor3jGQfSGHNNJcWxNbOI8v5k82prYqzPuwkzHt3J1T1iZQ==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"netbsd"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild/openbsd-x64": {
"version": "0.20.2",
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.20.2.tgz",
"integrity": "sha512-eMpKlV0SThJmmJgiVyN9jTPJ2VBPquf6Kt/nAoo6DgHAoN57K15ZghiHaMvqjCye/uU4X5u3YSMgVBI1h3vKrQ==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"openbsd"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild/sunos-x64": {
"version": "0.20.2",
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.20.2.tgz",
"integrity": "sha512-2UyFtRC6cXLyejf/YEld4Hajo7UHILetzE1vsRcGL3earZEW77JxrFjH4Ez2qaTiEfMgAXxfAZCm1fvM/G/o8w==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"sunos"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild/win32-arm64": {
"version": "0.20.2",
"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.20.2.tgz",
"integrity": "sha512-GRibxoawM9ZCnDxnP3usoUDO9vUkpAxIIZ6GQI+IlVmr5kP3zUq+l17xELTHMWTWzjxa2guPNyrpq1GWmPvcGQ==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild/win32-ia32": {
"version": "0.20.2",
"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.20.2.tgz",
"integrity": "sha512-HfLOfn9YWmkSKRQqovpnITazdtquEW8/SoHW7pWpuEeguaZI4QnCRW6b+oZTztdBnZOS2hqJ6im/D5cPzBTTlQ==",
"cpu": [
"ia32"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild/win32-x64": {
"version": "0.20.2",
"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.20.2.tgz",
"integrity": "sha512-N49X4lJX27+l9jbLKSqZ6bKNjzQvHaT8IIFUy+YIqmXQdjYCToGWwOItDrfby14c78aDd5NHQl29xingXfCdLQ==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@eslint-community/eslint-utils": {
"version": "4.5.1",
"resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.5.1.tgz",
@@ -311,13 +703,13 @@
}
},
"node_modules/@playwright/test": {
"version": "1.55.0-alpha-1752701791000",
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.55.0-alpha-1752701791000.tgz",
"integrity": "sha512-mnitdsjXKPyKTjQQDJ78Or1xZSGcaoDzZVD/0BWFCvygn3nyNmGmiias/Mlfvzvgz9UWBbPeZYxU/bd2Lu+OrQ==",
"version": "1.55.0-alpha-1753913825000",
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.55.0-alpha-1753913825000.tgz",
"integrity": "sha512-YM5YHU6nTYNVzXlKvQvtEdXzpubLvdfEiTxwWvbqGHL/iDK2kBJd3L0psIG6yClU1wy01O756TkOOQSEpzOu7g==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"playwright": "1.55.0-alpha-1752701791000"
"playwright": "1.55.0-alpha-1753913825000"
},
"bin": {
"playwright": "cli.js"
@@ -379,17 +771,6 @@
"url": "https://github.com/sponsors/jonschlinkert"
}
},
"node_modules/@types/chrome": {
"version": "0.0.315",
"resolved": "https://registry.npmjs.org/@types/chrome/-/chrome-0.0.315.tgz",
"integrity": "sha512-Oy1dYWkr6BCmgwBtOngLByCHstQ3whltZg7/7lubgIZEYvKobDneqplgc6LKERNRBwckFviV4UU5AZZNUFrJ4A==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/filesystem": "*",
"@types/har-format": "*"
}
},
"node_modules/@types/debug": {
"version": "4.1.12",
"resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.12.tgz",
@@ -401,33 +782,9 @@
}
},
"node_modules/@types/estree": {
"version": "1.0.6",
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.6.tgz",
"integrity": "sha512-AYnb1nQyY49te+VRAVgmzfcgjYS91mY5P0TKUDCLEM+gNnA+3T6rWITXRLYCpahpqSQbN5cE+gHpnPyXjHWxcw==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/filesystem": {
"version": "0.0.36",
"resolved": "https://registry.npmjs.org/@types/filesystem/-/filesystem-0.0.36.tgz",
"integrity": "sha512-vPDXOZuannb9FZdxgHnqSwAG/jvdGM8Wq+6N4D/d80z+D4HWH+bItqsZaVRQykAn6WEVeEkLm2oQigyHtgb0RA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/filewriter": "*"
}
},
"node_modules/@types/filewriter": {
"version": "0.0.33",
"resolved": "https://registry.npmjs.org/@types/filewriter/-/filewriter-0.0.33.tgz",
"integrity": "sha512-xFU8ZXTw4gd358lb2jw25nxY9QAgqn2+bKKjKOYfNCzN4DKCFetK7sPtrlpg66Ywe3vWY9FNxprZawAh9wfJ3g==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/har-format": {
"version": "1.2.16",
"resolved": "https://registry.npmjs.org/@types/har-format/-/har-format-1.2.16.tgz",
"integrity": "sha512-fluxdy7ryD3MV6h8pTfTYpy/xQzCFC7m89nOH9y94cNqJ1mDIDPut7MnRHI3F6qRmh/cT2fUjG1MLdCNb4hE9A==",
"version": "1.0.8",
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
"integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==",
"dev": true,
"license": "MIT"
},
@@ -1469,6 +1826,45 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/esbuild": {
"version": "0.20.2",
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.20.2.tgz",
"integrity": "sha512-WdOOppmUNU+IbZ0PaDiTst80zjnrOkyJNHoKupIcVyU8Lvla3Ugx94VzkQ32Ijqd7UhHJy75gNWDMUekcrSJ6g==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"bin": {
"esbuild": "bin/esbuild"
},
"engines": {
"node": ">=12"
},
"optionalDependencies": {
"@esbuild/aix-ppc64": "0.20.2",
"@esbuild/android-arm": "0.20.2",
"@esbuild/android-arm64": "0.20.2",
"@esbuild/android-x64": "0.20.2",
"@esbuild/darwin-arm64": "0.20.2",
"@esbuild/darwin-x64": "0.20.2",
"@esbuild/freebsd-arm64": "0.20.2",
"@esbuild/freebsd-x64": "0.20.2",
"@esbuild/linux-arm": "0.20.2",
"@esbuild/linux-arm64": "0.20.2",
"@esbuild/linux-ia32": "0.20.2",
"@esbuild/linux-loong64": "0.20.2",
"@esbuild/linux-mips64el": "0.20.2",
"@esbuild/linux-ppc64": "0.20.2",
"@esbuild/linux-riscv64": "0.20.2",
"@esbuild/linux-s390x": "0.20.2",
"@esbuild/linux-x64": "0.20.2",
"@esbuild/netbsd-x64": "0.20.2",
"@esbuild/openbsd-x64": "0.20.2",
"@esbuild/sunos-x64": "0.20.2",
"@esbuild/win32-arm64": "0.20.2",
"@esbuild/win32-ia32": "0.20.2",
"@esbuild/win32-x64": "0.20.2"
}
},
"node_modules/escape-html": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz",
@@ -3349,12 +3745,12 @@
}
},
"node_modules/playwright": {
"version": "1.55.0-alpha-1752701791000",
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.55.0-alpha-1752701791000.tgz",
"integrity": "sha512-PA3TvDz7uQ+Pde0uaii5/WpU5vntRJsYFsaSPoBzywIqzYFO1ugk1ZZ0q6z4/xHq0ha1UClvsv3P77B+u1fi+w==",
"version": "1.55.0-alpha-1753913825000",
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.55.0-alpha-1753913825000.tgz",
"integrity": "sha512-IDyZzTu3tRNIjcx7/6ZmU7VmZPFGaW4jNsizwqbjSoeLFZPTLx2y693qeVVF/8KwEjuiSU3hVTQEzWvnx7cf2Q==",
"license": "Apache-2.0",
"dependencies": {
"playwright-core": "1.55.0-alpha-1752701791000"
"playwright-core": "1.55.0-alpha-1753913825000"
},
"bin": {
"playwright": "cli.js"
@@ -3367,9 +3763,9 @@
}
},
"node_modules/playwright-core": {
"version": "1.55.0-alpha-1752701791000",
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.55.0-alpha-1752701791000.tgz",
"integrity": "sha512-mQhzhjJMiqnGNnYZv7M4yk1OcNTt1E72jrTLO7EqZuoeat4+qpcU0/mbK+RcTEass5a9YheoVFh6OIhruFMGVg==",
"version": "1.55.0-alpha-1753913825000",
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.55.0-alpha-1753913825000.tgz",
"integrity": "sha512-FH5pHzLseQxD8+d2wGlRa/I32AzJ+ZzcdDNM1aiSw5+gmq+aOo3PBqXHvhsh7tj0h4l2Qf6z9qf4mMiwijVthw==",
"license": "Apache-2.0",
"bin": {
"playwright-core": "cli.js"

View File

@@ -17,18 +17,16 @@
"license": "Apache-2.0",
"scripts": {
"build": "tsc",
"build:extension": "tsc --project extension",
"lint": "npm run update-readme && eslint . && tsc --noEmit",
"lint-fix": "eslint . --fix",
"update-readme": "node utils/update-readme.js",
"watch": "tsc --watch",
"watch:extension": "tsc --watch --project extension",
"test": "playwright test",
"ctest": "playwright test --project=chrome",
"ftest": "playwright test --project=firefox",
"wtest": "playwright test --project=webkit",
"run-server": "node lib/browserServer.js",
"clean": "rm -rf lib extension/lib",
"clean": "rm -rf lib",
"npm-publish": "npm run clean && npm run build && npm run test && npm publish"
},
"exports": {
@@ -44,24 +42,25 @@
"debug": "^4.4.1",
"dotenv": "^17.2.0",
"mime": "^4.0.7",
"playwright": "1.55.0-alpha-1752701791000",
"playwright-core": "1.55.0-alpha-1752701791000",
"playwright": "1.55.0-alpha-1753913825000",
"playwright-core": "1.55.0-alpha-1753913825000",
"ws": "^8.18.1",
"zod": "^3.24.1",
"zod-to-json-schema": "^3.24.4"
},
"devDependencies": {
"@anthropic-ai/sdk": "^0.57.0",
"@eslint/eslintrc": "^3.2.0",
"@eslint/js": "^9.19.0",
"@playwright/test": "1.55.0-alpha-1752701791000",
"@playwright/test": "1.55.0-alpha-1753913825000",
"@stylistic/eslint-plugin": "^3.0.1",
"@types/chrome": "^0.0.315",
"@types/debug": "^4.1.12",
"@types/node": "^22.13.10",
"@types/ws": "^8.18.1",
"@typescript-eslint/eslint-plugin": "^8.26.1",
"@typescript-eslint/parser": "^8.26.1",
"@typescript-eslint/utils": "^8.26.1",
"esbuild": "^0.20.1",
"eslint": "^9.19.0",
"eslint-plugin-import": "^2.31.0",
"eslint-plugin-notice": "^1.0.0",

172
src/actions.d.ts vendored Normal file
View File

@@ -0,0 +1,172 @@
/**
* 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.
*/
type Point = { x: number, y: number };
export type ActionName =
'check' |
'click' |
'closePage' |
'fill' |
'navigate' |
'openPage' |
'press' |
'select' |
'uncheck' |
'setInputFiles' |
'assertText' |
'assertValue' |
'assertChecked' |
'assertVisible' |
'assertSnapshot';
export type ActionBase = {
name: ActionName,
signals: Signal[],
ariaSnapshot?: string,
};
export type ActionWithSelector = ActionBase & {
selector: string,
ref?: string,
};
export type ClickAction = ActionWithSelector & {
name: 'click',
button: 'left' | 'middle' | 'right',
modifiers: number,
clickCount: number,
position?: Point,
};
export type CheckAction = ActionWithSelector & {
name: 'check',
};
export type UncheckAction = ActionWithSelector & {
name: 'uncheck',
};
export type FillAction = ActionWithSelector & {
name: 'fill',
text: string,
};
export type NavigateAction = ActionBase & {
name: 'navigate',
url: string,
};
export type OpenPageAction = ActionBase & {
name: 'openPage',
url: string,
};
export type ClosesPageAction = ActionBase & {
name: 'closePage',
};
export type PressAction = ActionWithSelector & {
name: 'press',
key: string,
modifiers: number,
};
export type SelectAction = ActionWithSelector & {
name: 'select',
options: string[],
};
export type SetInputFilesAction = ActionWithSelector & {
name: 'setInputFiles',
files: string[],
};
export type AssertTextAction = ActionWithSelector & {
name: 'assertText',
text: string,
substring: boolean,
};
export type AssertValueAction = ActionWithSelector & {
name: 'assertValue',
value: string,
};
export type AssertCheckedAction = ActionWithSelector & {
name: 'assertChecked',
checked: boolean,
};
export type AssertVisibleAction = ActionWithSelector & {
name: 'assertVisible',
};
export type AssertSnapshotAction = ActionWithSelector & {
name: 'assertSnapshot',
ariaSnapshot: string,
};
export type Action = ClickAction | CheckAction | ClosesPageAction | OpenPageAction | UncheckAction | FillAction | NavigateAction | PressAction | SelectAction | SetInputFilesAction | AssertTextAction | AssertValueAction | AssertCheckedAction | AssertVisibleAction | AssertSnapshotAction;
export type AssertAction = AssertCheckedAction | AssertValueAction | AssertTextAction | AssertVisibleAction | AssertSnapshotAction;
export type PerformOnRecordAction = ClickAction | CheckAction | UncheckAction | PressAction | SelectAction;
// Signals.
export type BaseSignal = {
};
export type NavigationSignal = BaseSignal & {
name: 'navigation',
url: string,
};
export type PopupSignal = BaseSignal & {
name: 'popup',
popupAlias: string,
};
export type DownloadSignal = BaseSignal & {
name: 'download',
downloadAlias: string,
};
export type DialogSignal = BaseSignal & {
name: 'dialog',
dialogAlias: string,
};
export type Signal = NavigationSignal | PopupSignal | DownloadSignal | DialogSignal;
export type FrameDescription = {
pageGuid: string;
pageAlias: string;
framePath: string[];
};
export type ActionInContext = {
frame: FrameDescription;
description?: string;
action: Action;
startTime: number;
endTime?: number;
};
export type SignalInContext = {
frame: FrameDescription;
signal: Signal;
timestamp: number;
};

View File

@@ -14,39 +14,48 @@
* limitations under the License.
*/
import fs from 'node:fs';
import net from 'node:net';
import path from 'node:path';
import os from 'node:os';
import fs from 'fs';
import net from 'net';
import path from 'path';
import * as playwright from 'playwright';
// @ts-ignore
import { registryDirectory } from 'playwright-core/lib/server/registry/index';
import { logUnhandledError, testDebug } from './log.js';
import { createHash } from './utils.js';
import { outputFile } from './config.js';
import type { FullConfig } from './config.js';
export function contextFactory(browserConfig: FullConfig['browser']): BrowserContextFactory {
if (browserConfig.remoteEndpoint)
return new RemoteContextFactory(browserConfig);
if (browserConfig.cdpEndpoint)
return new CdpContextFactory(browserConfig);
if (browserConfig.isolated)
return new IsolatedContextFactory(browserConfig);
return new PersistentContextFactory(browserConfig);
export function contextFactory(config: FullConfig): BrowserContextFactory {
if (config.browser.remoteEndpoint)
return new RemoteContextFactory(config);
if (config.browser.cdpEndpoint)
return new CdpContextFactory(config);
if (config.browser.isolated)
return new IsolatedContextFactory(config);
return new PersistentContextFactory(config);
}
export type ClientInfo = { name?: string, version?: string, rootPath?: string };
export interface BrowserContextFactory {
createContext(clientInfo: { name: string, version: string }): Promise<{ browserContext: playwright.BrowserContext, close: () => Promise<void> }>;
readonly name: string;
readonly description: string;
createContext(clientInfo: ClientInfo, abortSignal: AbortSignal): Promise<{ browserContext: playwright.BrowserContext, close: () => Promise<void> }>;
}
class BaseContextFactory implements BrowserContextFactory {
readonly browserConfig: FullConfig['browser'];
protected _browserPromise: Promise<playwright.Browser> | undefined;
readonly name: string;
readonly description: string;
readonly config: FullConfig;
protected _browserPromise: Promise<playwright.Browser> | undefined;
protected _tracesDir: string | undefined;
constructor(name: string, browserConfig: FullConfig['browser']) {
constructor(name: string, description: string, config: FullConfig) {
this.name = name;
this.browserConfig = browserConfig;
this.description = description;
this.config = config;
}
protected async _obtainBrowser(): Promise<playwright.Browser> {
@@ -68,7 +77,10 @@ class BaseContextFactory implements BrowserContextFactory {
throw new Error('Not implemented');
}
async createContext(): Promise<{ browserContext: playwright.BrowserContext, close: () => Promise<void> }> {
async createContext(clientInfo: ClientInfo): Promise<{ browserContext: playwright.BrowserContext, close: () => Promise<void> }> {
if (this.config.saveTrace)
this._tracesDir = await outputFile(this.config, clientInfo.rootPath, `traces-${Date.now()}`);
testDebug(`create browser context (${this.name})`);
const browser = await this._obtainBrowser();
const browserContext = await this._doCreateContext(browser);
@@ -92,15 +104,16 @@ class BaseContextFactory implements BrowserContextFactory {
}
class IsolatedContextFactory extends BaseContextFactory {
constructor(browserConfig: FullConfig['browser']) {
super('isolated', browserConfig);
constructor(config: FullConfig) {
super('isolated', 'Create a new isolated browser context', config);
}
protected override async _doObtainBrowser(): Promise<playwright.Browser> {
await injectCdpPort(this.browserConfig);
const browserType = playwright[this.browserConfig.browserName];
await injectCdpPort(this.config.browser);
const browserType = playwright[this.config.browser.browserName];
return browserType.launch({
...this.browserConfig.launchOptions,
tracesDir: this._tracesDir,
...this.config.browser.launchOptions,
handleSIGINT: false,
handleSIGTERM: false,
}).catch(error => {
@@ -111,64 +124,71 @@ class IsolatedContextFactory extends BaseContextFactory {
}
protected override async _doCreateContext(browser: playwright.Browser): Promise<playwright.BrowserContext> {
return browser.newContext(this.browserConfig.contextOptions);
return browser.newContext(this.config.browser.contextOptions);
}
}
class CdpContextFactory extends BaseContextFactory {
constructor(browserConfig: FullConfig['browser']) {
super('cdp', browserConfig);
constructor(config: FullConfig) {
super('cdp', 'Connect to a browser over CDP', config);
}
protected override async _doObtainBrowser(): Promise<playwright.Browser> {
return playwright.chromium.connectOverCDP(this.browserConfig.cdpEndpoint!);
return playwright.chromium.connectOverCDP(this.config.browser.cdpEndpoint!);
}
protected override async _doCreateContext(browser: playwright.Browser): Promise<playwright.BrowserContext> {
return this.browserConfig.isolated ? await browser.newContext() : browser.contexts()[0];
return this.config.browser.isolated ? await browser.newContext(this.config.browser.contextOptions) : browser.contexts()[0];
}
}
class RemoteContextFactory extends BaseContextFactory {
constructor(browserConfig: FullConfig['browser']) {
super('remote', browserConfig);
constructor(config: FullConfig) {
super('remote', 'Connect to a browser using a remote endpoint', config);
}
protected override async _doObtainBrowser(): Promise<playwright.Browser> {
const url = new URL(this.browserConfig.remoteEndpoint!);
url.searchParams.set('browser', this.browserConfig.browserName);
if (this.browserConfig.launchOptions)
url.searchParams.set('launch-options', JSON.stringify(this.browserConfig.launchOptions));
return playwright[this.browserConfig.browserName].connect(String(url));
const url = new URL(this.config.browser.remoteEndpoint!);
url.searchParams.set('browser', this.config.browser.browserName);
if (this.config.browser.launchOptions)
url.searchParams.set('launch-options', JSON.stringify(this.config.browser.launchOptions));
return playwright[this.config.browser.browserName].connect(String(url));
}
protected override async _doCreateContext(browser: playwright.Browser): Promise<playwright.BrowserContext> {
return browser.newContext();
return browser.newContext(this.config.browser.contextOptions);
}
}
class PersistentContextFactory implements BrowserContextFactory {
readonly browserConfig: FullConfig['browser'];
readonly config: FullConfig;
readonly name = 'persistent';
readonly description = 'Create a new persistent browser context';
private _userDataDirs = new Set<string>();
constructor(browserConfig: FullConfig['browser']) {
this.browserConfig = browserConfig;
constructor(config: FullConfig) {
this.config = config;
}
async createContext(): Promise<{ browserContext: playwright.BrowserContext, close: () => Promise<void> }> {
await injectCdpPort(this.browserConfig);
async createContext(clientInfo: ClientInfo): Promise<{ browserContext: playwright.BrowserContext, close: () => Promise<void> }> {
await injectCdpPort(this.config.browser);
testDebug('create browser context (persistent)');
const userDataDir = this.browserConfig.userDataDir ?? await this._createUserDataDir();
const userDataDir = this.config.browser.userDataDir ?? await this._createUserDataDir(clientInfo.rootPath);
let tracesDir: string | undefined;
if (this.config.saveTrace)
tracesDir = await outputFile(this.config, clientInfo.rootPath, `traces-${Date.now()}`);
this._userDataDirs.add(userDataDir);
testDebug('lock user data dir', userDataDir);
const browserType = playwright[this.browserConfig.browserName];
const browserType = playwright[this.config.browser.browserName];
for (let i = 0; i < 5; i++) {
try {
const browserContext = await browserType.launchPersistentContext(userDataDir, {
...this.browserConfig.launchOptions,
...this.browserConfig.contextOptions,
tracesDir,
...this.config.browser.launchOptions,
...this.config.browser.contextOptions,
handleSIGINT: false,
handleSIGTERM: false,
});
@@ -196,17 +216,12 @@ class PersistentContextFactory implements BrowserContextFactory {
testDebug('close browser context complete (persistent)');
}
private async _createUserDataDir() {
let cacheDirectory: string;
if (process.platform === 'linux')
cacheDirectory = process.env.XDG_CACHE_HOME || path.join(os.homedir(), '.cache');
else if (process.platform === 'darwin')
cacheDirectory = path.join(os.homedir(), 'Library', 'Caches');
else if (process.platform === 'win32')
cacheDirectory = process.env.LOCALAPPDATA || path.join(os.homedir(), 'AppData', 'Local');
else
throw new Error('Unsupported platform: ' + process.platform);
const result = path.join(cacheDirectory, 'ms-playwright', `mcp-${this.browserConfig.launchOptions?.channel ?? this.browserConfig?.browserName}-profile`);
private async _createUserDataDir(rootPath: string | undefined) {
const dir = process.env.PWMCP_PROFILES_DIR_FOR_TEST ?? registryDirectory;
const browserToken = this.config.browser.launchOptions?.channel ?? this.config.browser?.browserName;
// Hesitant putting hundreds of files into the user's workspace, so using it for hashing instead.
const rootPathToken = rootPath ? `-${createHash(rootPath)}` : '';
const result = path.join(dir, `mcp-${browserToken}${rootPathToken}`);
await fs.promises.mkdir(result, { recursive: true });
return result;
}

View File

@@ -14,6 +14,8 @@
* limitations under the License.
*/
import { fileURLToPath } from 'url';
import { z } from 'zod';
import { FullConfig } from './config.js';
import { Context } from './context.js';
import { logUnhandledError } from './log.js';
@@ -21,28 +23,52 @@ import { Response } from './response.js';
import { SessionLog } from './sessionLog.js';
import { filteredTools } from './tools.js';
import { packageJSON } from './package.js';
import { defineTool } from './tools/tool.js';
import type { Tool } from './tools/tool.js';
import type { BrowserContextFactory } from './browserContextFactory.js';
import type * as mcpServer from './mcp/server.js';
import type { ServerBackend } from './mcp/server.js';
import type { Tool } from './tools/tool.js';
type NonEmptyArray<T> = [T, ...T[]];
export type FactoryList = NonEmptyArray<BrowserContextFactory>;
export class BrowserServerBackend implements ServerBackend {
name = 'Playwright';
version = packageJSON.version;
onclose?: () => void;
private _tools: Tool[];
private _context: Context;
private _context: Context | undefined;
private _sessionLog: SessionLog | undefined;
private _config: FullConfig;
private _browserContextFactory: BrowserContextFactory;
constructor(config: FullConfig, browserContextFactory: BrowserContextFactory) {
constructor(config: FullConfig, factories: FactoryList) {
this._config = config;
this._browserContextFactory = factories[0];
this._tools = filteredTools(config);
this._context = new Context(this._tools, config, browserContextFactory);
if (factories.length > 1)
this._tools.push(this._defineContextSwitchTool(factories));
}
async initialize() {
this._sessionLog = this._context.config.saveSession ? await SessionLog.create(this._context.config) : undefined;
async initialize(server: mcpServer.Server): Promise<void> {
const capabilities = server.getClientCapabilities() as mcpServer.ClientCapabilities;
let rootPath: string | undefined;
if (capabilities.roots) {
const { roots } = await server.listRoots();
const firstRootUri = roots[0]?.uri;
const url = firstRootUri ? new URL(firstRootUri) : undefined;
rootPath = url ? fileURLToPath(url) : undefined;
}
this._sessionLog = this._config.saveSession ? await SessionLog.create(this._config, rootPath) : undefined;
this._context = new Context({
tools: this._tools,
config: this._config,
browserContextFactory: this._browserContextFactory,
sessionLog: this._sessionLog,
clientInfo: { ...server.getClientVersion(), rootPath },
});
}
tools(): mcpServer.ToolSchema<any>[] {
@@ -50,20 +76,65 @@ export class BrowserServerBackend implements ServerBackend {
}
async callTool(schema: mcpServer.ToolSchema<any>, parsedArguments: any) {
const response = new Response(this._context, schema.name, parsedArguments);
const context = this._context!;
const response = new Response(context, schema.name, parsedArguments);
const tool = this._tools.find(tool => tool.schema.name === schema.name)!;
await tool.handle(this._context, parsedArguments, response);
if (this._sessionLog)
await this._sessionLog.log(response);
return await response.serialize();
}
serverInitialized(version: mcpServer.ClientVersion | undefined) {
this._context.clientVersion = version;
context.setRunningTool(true);
try {
await tool.handle(context, parsedArguments, response);
await response.finish();
this._sessionLog?.logResponse(response);
} catch (error: any) {
response.addError(String(error));
} finally {
context.setRunningTool(false);
}
return response.serialize();
}
serverClosed() {
this.onclose?.();
void this._context.dispose().catch(logUnhandledError);
void this._context!.dispose().catch(logUnhandledError);
}
private _defineContextSwitchTool(factories: FactoryList): Tool<any> {
const self = this;
return defineTool({
capability: 'core',
schema: {
name: 'browser_connect',
title: 'Connect to a browser context',
description: [
'Connect to a browser using one of the available methods:',
...factories.map(factory => `- "${factory.name}": ${factory.description}`),
].join('\n'),
inputSchema: z.object({
method: z.enum(factories.map(factory => factory.name) as [string, ...string[]]).default(factories[0].name).describe('The method to use to connect to the browser'),
}),
type: 'readOnly',
},
async handle(context, params, response) {
const factory = factories.find(factory => factory.name === params.method);
if (!factory) {
response.addError('Unknown connection method: ' + params.method);
return;
}
await self._setContextFactory(factory);
response.addResult('Successfully changed connection method.');
}
});
}
private async _setContextFactory(newFactory: BrowserContextFactory) {
if (this._context) {
const options = {
...this._context.options,
browserContextFactory: newFactory,
};
await this._context.dispose();
this._context = new Context(options);
}
this._browserContextFactory = newFactory;
}
}

View File

@@ -18,7 +18,8 @@ import fs from 'fs';
import os from 'os';
import path from 'path';
import { devices } from 'playwright';
import { sanitizeForFilePath } from './tools/utils.js';
import { sanitizeForFilePath } from './utils.js';
import type { Config, ToolCapability } from '../config.js';
import type { BrowserContextOptions, LaunchOptions } from 'playwright';
@@ -39,6 +40,7 @@ export type CLIOptions = {
imageResponses?: 'allow' | 'omit';
sandbox?: boolean;
outputDir?: string;
permissions?: string[];
port?: number;
proxyBypass?: string;
proxyServer?: string;
@@ -67,7 +69,7 @@ const defaultConfig: FullConfig = {
blockedOrigins: undefined,
},
server: {},
outputDir: path.join(os.tmpdir(), 'playwright-mcp-output', sanitizeForFilePath(new Date().toISOString())),
saveTrace: false,
};
type BrowserUserConfig = NonNullable<Config['browser']>;
@@ -79,7 +81,7 @@ export type FullConfig = Config & {
contextOptions: NonNullable<BrowserUserConfig['contextOptions']>;
},
network: NonNullable<Config['network']>,
outputDir: string;
saveTrace: boolean;
server: NonNullable<Config['server']>,
};
@@ -95,9 +97,6 @@ export async function resolveCLIConfig(cliOptions: CLIOptions): Promise<FullConf
result = mergeConfig(result, configInFile);
result = mergeConfig(result, envOverrides);
result = mergeConfig(result, cliOverrides);
// Derive artifact output directory from config.outputDir
if (result.saveTrace)
result.browser.launchOptions.tracesDir = path.join(result.outputDir, 'traces');
return result;
}
@@ -172,6 +171,9 @@ export function configFromCLIOptions(cliOptions: CLIOptions): Config {
if (cliOptions.blockServiceWorkers)
contextOptions.serviceWorkers = 'block';
if (cliOptions.permissions)
contextOptions.permissions = cliOptions.permissions;
const result: Config = {
browser: {
browserName,
@@ -218,6 +220,7 @@ function configFromEnv(): Config {
options.imageResponses = 'omit';
options.sandbox = envToBoolean(process.env.PLAYWRIGHT_MCP_SANDBOX);
options.outputDir = envToString(process.env.PLAYWRIGHT_MCP_OUTPUT_DIR);
options.permissions = commaSeparatedList(process.env.PLAYWRIGHT_MCP_PERMISSIONS);
options.port = envToNumber(process.env.PLAYWRIGHT_MCP_PORT);
options.proxyBypass = envToString(process.env.PLAYWRIGHT_MCP_PROXY_BYPASS);
options.proxyServer = envToString(process.env.PLAYWRIGHT_MCP_PROXY_SERVER);
@@ -240,10 +243,14 @@ async function loadConfig(configFile: string | undefined): Promise<Config> {
}
}
export async function outputFile(config: FullConfig, name: string): Promise<string> {
await fs.promises.mkdir(config.outputDir, { recursive: true });
export async function outputFile(config: FullConfig, rootPath: string | undefined, name: string): Promise<string> {
const outputDir = config.outputDir
?? (rootPath ? path.join(rootPath, '.playwright-mcp') : undefined)
?? path.join(os.tmpdir(), 'playwright-mcp-output', sanitizeForFilePath(new Date().toISOString()));
await fs.promises.mkdir(outputDir, { recursive: true });
const fileName = sanitizeForFilePath(name);
return path.join(config.outputDir, fileName);
return path.join(outputDir, fileName);
}
function pickDefined<T extends object>(obj: T | undefined): Partial<T> {

View File

@@ -19,29 +19,47 @@ import * as playwright from 'playwright';
import { logUnhandledError } from './log.js';
import { Tab } from './tab.js';
import { outputFile } from './config.js';
import type { Tool } from './tools/tool.js';
import type { FullConfig } from './config.js';
import type { BrowserContextFactory } from './browserContextFactory.js';
import type { Tool } from './tools/tool.js';
import type { BrowserContextFactory, ClientInfo } from './browserContextFactory.js';
import type * as actions from './actions.js';
import type { SessionLog } from './sessionLog.js';
const testDebug = debug('pw:mcp:test');
type ContextOptions = {
tools: Tool[];
config: FullConfig;
browserContextFactory: BrowserContextFactory;
sessionLog: SessionLog | undefined;
clientInfo: ClientInfo;
};
export class Context {
readonly tools: Tool[];
readonly config: FullConfig;
readonly sessionLog: SessionLog | undefined;
readonly options: ContextOptions;
private _browserContextPromise: Promise<{ browserContext: playwright.BrowserContext, close: () => Promise<void> }> | undefined;
private _browserContextFactory: BrowserContextFactory;
private _tabs: Tab[] = [];
private _currentTab: Tab | undefined;
clientVersion: { name: string; version: string; } | undefined;
private _clientInfo: ClientInfo;
private static _allContexts: Set<Context> = new Set();
private _closeBrowserContextPromise: Promise<void> | undefined;
private _isRunningTool: boolean = false;
private _abortController = new AbortController();
constructor(tools: Tool[], config: FullConfig, browserContextFactory: BrowserContextFactory) {
this.tools = tools;
this.config = config;
this._browserContextFactory = browserContextFactory;
constructor(options: ContextOptions) {
this.tools = options.tools;
this.config = options.config;
this.sessionLog = options.sessionLog;
this.options = options;
this._browserContextFactory = options.browserContextFactory;
this._clientInfo = options.clientInfo;
testDebug('create context');
Context._allContexts.add(this);
}
@@ -87,30 +105,6 @@ export class Context {
return this._currentTab!;
}
async listTabsMarkdown(force: boolean = false): Promise<string[]> {
if (this._tabs.length === 1 && !force)
return [];
if (!this._tabs.length) {
return [
'### No open tabs',
'Use the "browser_navigate" tool to navigate to a page first.',
'',
];
}
const lines: string[] = ['### Open tabs'];
for (let i = 0; i < this._tabs.length; i++) {
const tab = this._tabs[i];
const title = await tab.title();
const url = tab.page.url();
const current = tab === this._currentTab ? ' (current)' : '';
lines.push(`- ${i}:${current} [${title}] (${url})`);
}
lines.push('');
return lines;
}
async closeTab(index: number | undefined): Promise<string> {
const tab = index === undefined ? this._currentTab : this._tabs[index];
if (!tab)
@@ -120,6 +114,10 @@ export class Context {
return url;
}
async outputFile(name: string): Promise<string> {
return outputFile(this.config, this._clientInfo.rootPath, name);
}
private _onPageCreated(page: playwright.Page) {
const tab = new Tab(this, page, tab => this._onPageClosed(tab));
this._tabs.push(tab);
@@ -146,6 +144,14 @@ export class Context {
this._closeBrowserContextPromise = undefined;
}
isRunningTool() {
return this._isRunningTool;
}
setRunningTool(isRunningTool: boolean) {
this._isRunningTool = isRunningTool;
}
private async _closeBrowserContextImpl() {
if (!this._browserContextPromise)
return;
@@ -163,6 +169,7 @@ export class Context {
}
async dispose() {
this._abortController.abort('MCP context disposed');
await this.closeBrowserContext();
Context._allContexts.delete(this);
}
@@ -195,9 +202,11 @@ 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.clientVersion!);
const result = await this._browserContextFactory.createContext(this._clientInfo, this._abortController.signal);
const { browserContext } = result;
await this._setupRequestInterception(browserContext);
if (this.sessionLog)
await InputRecorder.create(this, browserContext);
for (const page of browserContext.pages())
this._onPageCreated(page);
browserContext.on('page', page => this._onPageCreated(page));
@@ -212,3 +221,56 @@ export class Context {
return result;
}
}
export class InputRecorder {
private _context: Context;
private _browserContext: playwright.BrowserContext;
private constructor(context: Context, browserContext: playwright.BrowserContext) {
this._context = context;
this._browserContext = browserContext;
}
static async create(context: Context, browserContext: playwright.BrowserContext) {
const recorder = new InputRecorder(context, browserContext);
await recorder._initialize();
return recorder;
}
private async _initialize() {
const sessionLog = this._context.sessionLog!;
await (this._browserContext as any)._enableRecorder({
mode: 'recording',
recorderMode: 'api',
}, {
actionAdded: (page: playwright.Page, data: actions.ActionInContext, code: string) => {
if (this._context.isRunningTool())
return;
const tab = Tab.forPage(page);
if (tab)
sessionLog.logUserAction(data.action, tab, code, false);
},
actionUpdated: (page: playwright.Page, data: actions.ActionInContext, code: string) => {
if (this._context.isRunningTool())
return;
const tab = Tab.forPage(page);
if (tab)
sessionLog.logUserAction(data.action, tab, code, true);
},
signalAdded: (page: playwright.Page, data: actions.SignalInContext) => {
if (this._context.isRunningTool())
return;
if (data.signal.name !== 'navigation')
return;
const tab = Tab.forPage(page);
const navigateAction: actions.Action = {
name: 'navigate',
url: data.signal.url,
signals: [],
};
if (tab)
sessionLog.logUserAction(navigateAction, tab, `await page.goto('${data.signal.url}');`, false);
},
});
}
}

View File

@@ -22,18 +22,18 @@
* - /extension/guid - Extension connection for chrome.debugger forwarding
*/
import http from 'http';
import { spawn } from 'child_process';
import { WebSocket, WebSocketServer } from 'ws';
import http from 'http';
import debug from 'debug';
import * as playwright from 'playwright';
// @ts-ignore
const { registry } = await import('playwright-core/lib/server/registry/index');
import { httpAddressToString, startHttpServer } from '../httpServer.js';
import { WebSocket, WebSocketServer } from 'ws';
import { httpAddressToString } from '../httpServer.js';
import { logUnhandledError } from '../log.js';
import { ManualPromise } from '../manualPromise.js';
import type { BrowserContextFactory } from '../browserContextFactory.js';
import type websocket from 'ws';
import type { ClientInfo } from '../browserContextFactory.js';
// @ts-ignore
const { registry } = await import('playwright-core/lib/server/registry/index');
const debugLogger = debug('pw:mcp:relay');
@@ -90,20 +90,23 @@ export class CDPRelayServer {
return `${this._wsHost}${this._extensionPath}`;
}
async ensureExtensionConnectionForMCPContext(clientInfo: { name: string, version: string }) {
async ensureExtensionConnectionForMCPContext(clientInfo: ClientInfo, abortSignal: AbortSignal) {
debugLogger('Ensuring extension connection for MCP context');
if (this._extensionConnection)
return;
await this._connectBrowser(clientInfo);
this._connectBrowser(clientInfo);
debugLogger('Waiting for incoming extension connection');
await this._extensionConnectionPromise;
await Promise.race([
this._extensionConnectionPromise,
new Promise((_, reject) => abortSignal.addEventListener('abort', reject))
]);
debugLogger('Extension connection established');
}
private async _connectBrowser(clientInfo: { name: string, version: string }) {
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');
const url = new URL('chrome-extension://jakfalbnbhgkpmoaakfflhflbfpkailf/lib/ui/connect.html');
url.searchParams.set('mcpRelayUrl', mcpRelayEndpoint);
url.searchParams.set('client', JSON.stringify(clientInfo));
const href = url.toString();
@@ -300,51 +303,6 @@ export class CDPRelayServer {
}
}
class ExtensionContextFactory implements BrowserContextFactory {
private _relay: CDPRelayServer;
private _browserPromise: Promise<playwright.Browser> | undefined;
constructor(relay: CDPRelayServer) {
this._relay = relay;
}
async createContext(clientInfo: { name: string, version: string }): Promise<{ browserContext: playwright.BrowserContext, close: () => Promise<void> }> {
// First call will establish the connection to the extension.
if (!this._browserPromise)
this._browserPromise = this._obtainBrowser(clientInfo);
const browser = await this._browserPromise;
return {
browserContext: browser.contexts()[0],
close: async () => {
debugLogger('close() called for browser context, ignoring');
}
};
}
clientDisconnected() {
this._relay.closeConnections('MCP client disconnected');
this._browserPromise = undefined;
}
private async _obtainBrowser(clientInfo: { name: string, version: string }): Promise<playwright.Browser> {
await this._relay.ensureExtensionConnectionForMCPContext(clientInfo);
const browser = await playwright.chromium.connectOverCDP(this._relay.cdpEndpoint());
browser.on('disconnected', () => {
this._browserPromise = undefined;
debugLogger('Browser disconnected');
});
return browser;
}
}
export async function startCDPRelayServer(browserChannel: string, abortController: AbortController) {
const httpServer = await startHttpServer({});
const cdpRelayServer = new CDPRelayServer(httpServer, browserChannel);
abortController.signal.addEventListener('abort', () => cdpRelayServer.stop());
debugLogger(`CDP relay server started, extension endpoint: ${cdpRelayServer.extensionEndpoint()}.`);
return new ExtensionContextFactory(cdpRelayServer);
}
type ExtensionResponse = {
id?: number;
method?: string;

View File

@@ -0,0 +1,78 @@
/**
* 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 * as playwright from 'playwright';
import { startHttpServer } from '../httpServer.js';
import { CDPRelayServer } from './cdpRelay.js';
import type { BrowserContextFactory, ClientInfo } from '../browserContextFactory.js';
const debugLogger = debug('pw:mcp:relay');
export class ExtensionContextFactory implements BrowserContextFactory {
name = 'extension';
description = 'Connect to a browser using the Playwright MCP extension';
private _browserChannel: string;
private _relayPromise: Promise<CDPRelayServer> | undefined;
private _browserPromise: Promise<playwright.Browser> | undefined;
constructor(browserChannel: string) {
this._browserChannel = browserChannel;
}
async createContext(clientInfo: ClientInfo, abortSignal: AbortSignal): Promise<{ browserContext: playwright.BrowserContext, close: () => Promise<void> }> {
// First call will establish the connection to the extension.
if (!this._browserPromise)
this._browserPromise = this._obtainBrowser(clientInfo, abortSignal);
const browser = await this._browserPromise;
return {
browserContext: browser.contexts()[0],
close: async () => {
debugLogger('close() called for browser context');
await browser.close();
this._browserPromise = undefined;
}
};
}
private async _obtainBrowser(clientInfo: ClientInfo, abortSignal: AbortSignal): Promise<playwright.Browser> {
if (!this._relayPromise)
this._relayPromise = this._startRelay(abortSignal);
const relay = await this._relayPromise;
abortSignal.throwIfAborted();
await relay.ensureExtensionConnectionForMCPContext(clientInfo, abortSignal);
const browser = await playwright.chromium.connectOverCDP(relay.cdpEndpoint());
browser.on('disconnected', () => {
this._browserPromise = undefined;
debugLogger('Browser disconnected');
});
return browser;
}
private async _startRelay(abortSignal: AbortSignal) {
const httpServer = await startHttpServer({});
const cdpRelayServer = new CDPRelayServer(httpServer, this._browserChannel);
debugLogger(`CDP relay server started, extension endpoint: ${cdpRelayServer.extensionEndpoint()}.`);
if (abortSignal.aborted)
cdpRelayServer.stop();
else
abortSignal.addEventListener('abort', () => cdpRelayServer.stop());
return cdpRelayServer;
}
}

View File

@@ -14,26 +14,18 @@
* limitations under the License.
*/
import { startCDPRelayServer } from './cdpRelay.js';
import { ExtensionContextFactory } from './extensionContextFactory.js';
import { BrowserServerBackend } from '../browserServerBackend.js';
import * as mcpTransport from '../mcp/transport.js';
import type { FullConfig } from '../config.js';
export async function runWithExtension(config: FullConfig, abortController: AbortController) {
const contextFactory = await startCDPRelayServer(config.browser.launchOptions.channel || 'chrome', abortController);
let backend: BrowserServerBackend | undefined;
const serverBackendFactory = () => {
if (backend)
throw new Error('Another MCP client is still connected. Only one connection at a time is allowed.');
backend = new BrowserServerBackend(config, contextFactory);
backend.onclose = () => {
contextFactory.clientDisconnected();
backend = undefined;
};
return backend;
};
export async function runWithExtension(config: FullConfig) {
const contextFactory = new ExtensionContextFactory(config.browser.launchOptions.channel || 'chrome');
const serverBackendFactory = () => new BrowserServerBackend(config, [contextFactory]);
await mcpTransport.start(serverBackendFactory, config.server);
}
export function createExtensionContextFactory(config: FullConfig) {
return new ExtensionContextFactory(config.browser.launchOptions.channel || 'chrome');
}

View File

@@ -26,11 +26,14 @@ import type { Server } from '@modelcontextprotocol/sdk/server/index.js';
export async function createConnection(userConfig: Config = {}, contextGetter?: () => Promise<BrowserContext>): Promise<Server> {
const config = await resolveConfig(userConfig);
const factory = contextGetter ? new SimpleBrowserContextFactory(contextGetter) : contextFactory(config.browser);
return mcpServer.createServer(new BrowserServerBackend(config, factory), false);
const factory = contextGetter ? new SimpleBrowserContextFactory(contextGetter) : contextFactory(config);
return mcpServer.createServer(new BrowserServerBackend(config, [factory]), false);
}
class SimpleBrowserContextFactory implements BrowserContextFactory {
name = 'custom';
description = 'Connect to a browser using a custom context getter';
private readonly _contextGetter: () => Promise<BrowserContext>;
constructor(contextGetter: () => Promise<BrowserContext>) {

View File

@@ -45,8 +45,8 @@ export class Context {
static async create(config: FullConfig) {
const client = new Client({ name: 'Playwright Proxy', version: '1.0.0' });
const browserContextFactory = contextFactory(config.browser);
const server = mcpServer.createServer(new BrowserServerBackend(config, browserContextFactory), false);
const browserContextFactory = contextFactory(config);
const server = mcpServer.createServer(new BrowserServerBackend(config, [browserContextFactory]), false);
await client.connect(new InProcessTransport(server));
await client.ping();
return new Context(config, client);

View File

@@ -18,11 +18,18 @@ import { z } from 'zod';
import { Server } from '@modelcontextprotocol/sdk/server/index.js';
import { CallToolRequestSchema, ListToolsRequestSchema } from '@modelcontextprotocol/sdk/types.js';
import { zodToJsonSchema } from 'zod-to-json-schema';
import { ManualPromise } from '../manualPromise.js';
import { logUnhandledError } from '../log.js';
import type { ImageContent, Implementation, TextContent } from '@modelcontextprotocol/sdk/types.js';
import type { ImageContent, TextContent } from '@modelcontextprotocol/sdk/types.js';
import type { Transport } from '@modelcontextprotocol/sdk/shared/transport.js';
export type { Server } from '@modelcontextprotocol/sdk/server/index.js';
export type ClientVersion = Implementation;
export type ClientCapabilities = {
roots?: {
listRoots?: boolean
};
};
export type ToolResponse = {
content: (TextContent | ImageContent)[];
@@ -42,10 +49,9 @@ export type ToolHandler = (toolName: string, params: any) => Promise<ToolRespons
export interface ServerBackend {
name: string;
version: string;
initialize?(): Promise<void>;
initialize?(server: Server): Promise<void>;
tools(): ToolSchema<any>[];
callTool(schema: ToolSchema<any>, parsedArguments: any): Promise<ToolResponse>;
serverInitialized?(version: ClientVersion | undefined): void;
serverClosed?(): void;
}
@@ -53,12 +59,12 @@ export type ServerBackendFactory = () => ServerBackend;
export async function connect(serverBackendFactory: ServerBackendFactory, transport: Transport, runHeartbeat: boolean) {
const backend = serverBackendFactory();
await backend.initialize?.();
const server = createServer(backend, runHeartbeat);
await server.connect(transport);
}
export function createServer(backend: ServerBackend, runHeartbeat: boolean): Server {
const initializedPromise = new ManualPromise<void>();
const server = new Server({ name: backend.name, version: backend.version }, {
capabilities: {
tools: {},
@@ -82,18 +88,20 @@ export function createServer(backend: ServerBackend, runHeartbeat: boolean): Ser
let heartbeatRunning = false;
server.setRequestHandler(CallToolRequestSchema, async request => {
await initializedPromise;
if (runHeartbeat && !heartbeatRunning) {
heartbeatRunning = true;
startHeartbeat(server);
}
const errorResult = (...messages: string[]) => ({
content: [{ type: 'text', text: messages.join('\n') }],
content: [{ type: 'text', text: '### Result\n' + messages.join('\n') }],
isError: true,
});
const tool = tools.find(tool => tool.name === request.params.name) as ToolSchema<any>;
if (!tool)
return errorResult(`Tool "${request.params.name}" not found`);
return errorResult(`Error: Tool "${request.params.name}" not found`);
try {
return await backend.callTool(tool, tool.inputSchema.parse(request.params.arguments || {}));
@@ -101,8 +109,9 @@ export function createServer(backend: ServerBackend, runHeartbeat: boolean): Ser
return errorResult(String(error));
}
});
addServerListener(server, 'initialized', () => backend.serverInitialized?.(server.getClientVersion()));
addServerListener(server, 'initialized', () => {
backend.initialize?.(server).then(() => initializedPromise.resolve()).catch(logUnhandledError);
});
addServerListener(server, 'close', () => backend.serverClosed?.());
return server;
}

View File

@@ -21,8 +21,8 @@ import { startTraceViewerServer } from 'playwright-core/lib/server';
import * as mcpTransport from './mcp/transport.js';
import { commaSeparatedList, resolveCLIConfig, semicolonSeparatedList } from './config.js';
import { packageJSON } from './package.js';
import { runWithExtension } from './extension/main.js';
import { BrowserServerBackend } from './browserServerBackend.js';
import { createExtensionContextFactory, runWithExtension } from './extension/main.js';
import { BrowserServerBackend, FactoryList } from './browserServerBackend.js';
import { Context } from './context.js';
import { contextFactory } from './browserContextFactory.js';
import { runLoopTools } from './loopTools/main.js';
@@ -46,6 +46,7 @@ program
.option('--image-responses <mode>', 'whether to send image responses to the client. Can be "allow" or "omit", Defaults to "allow".')
.option('--no-sandbox', 'disable the sandbox for all process types that are normally sandboxed.')
.option('--output-dir <path>', 'path to the directory for output files.')
.option('--permissions <permissions>', 'comma-separated list of permissions to grant, for example "clipboard-read,clipboard-write"', commaSeparatedList)
.option('--port <port>', 'port to listen on for SSE transport.')
.option('--proxy-bypass <bypass>', 'comma-separated domains to bypass proxy, for example ".com,chromium.org,.domain.com"')
.option('--proxy-server <proxy>', 'specify proxy server, for example "http://myproxy:3128" or "socks5://myproxy:8080"')
@@ -56,10 +57,11 @@ program
.option('--user-data-dir <path>', 'path to the user data directory. If not specified, a temporary directory will be created.')
.option('--viewport-size <size>', 'specify browser viewport size in pixels, for example "1280, 720"')
.addOption(new Option('--extension', 'Connect to a running browser instance (Edge/Chrome only). Requires the "Playwright MCP Bridge" browser extension to be installed.').hideHelp())
.addOption(new Option('--connect-tool', 'Allow to switch between different browser connection methods.').hideHelp())
.addOption(new Option('--loop-tools', 'Run loop tools').hideHelp())
.addOption(new Option('--vision', 'Legacy option, use --caps=vision instead').hideHelp())
.action(async options => {
const abortController = setupExitWatchdog();
setupExitWatchdog();
if (options.vision) {
// eslint-disable-next-line no-console
@@ -69,7 +71,7 @@ program
const config = await resolveCLIConfig(options);
if (options.extension) {
await runWithExtension(config, abortController);
await runWithExtension(config);
return;
}
if (options.loopTools) {
@@ -77,8 +79,11 @@ program
return;
}
const browserContextFactory = contextFactory(config.browser);
const serverBackendFactory = () => new BrowserServerBackend(config, browserContextFactory);
const browserContextFactory = contextFactory(config);
const factories: FactoryList = [browserContextFactory];
if (options.connectTool)
factories.push(createExtensionContextFactory(config));
const serverBackendFactory = () => new BrowserServerBackend(config, factories);
await mcpTransport.start(serverBackendFactory, config.server);
if (config.saveTrace) {
@@ -91,15 +96,12 @@ program
});
function setupExitWatchdog() {
const abortController = new AbortController();
let isExiting = false;
const handleExit = async () => {
if (isExiting)
return;
isExiting = true;
setTimeout(() => process.exit(0), 15000);
abortController.abort('Process exiting');
await Context.disposeAll();
process.exit(0);
};
@@ -107,8 +109,6 @@ function setupExitWatchdog() {
process.stdin.on('close', handleExit);
process.on('SIGINT', handleExit);
process.on('SIGTERM', handleExit);
return abortController;
}
void program.parseAsync(process.argv);

View File

@@ -14,7 +14,10 @@
* limitations under the License.
*/
import type { ImageContent, TextContent } from '@modelcontextprotocol/sdk/types.js';
import { renderModalStates } from './tab.js';
import type { Tab, TabSnapshot } from './tab.js';
import type { ImageContent, TextContent } from '@modelcontextprotocol/sdk/types.js';
import type { Context } from './context.js';
export class Response {
@@ -24,10 +27,11 @@ export class Response {
private _context: Context;
private _includeSnapshot = false;
private _includeTabs = false;
private _snapshot: string | undefined;
private _tabSnapshot: TabSnapshot | undefined;
readonly toolName: string;
readonly toolArgs: Record<string, any>;
private _isError: boolean | undefined;
constructor(context: Context, toolName: string, toolArgs: Record<string, any>) {
this._context = context;
@@ -39,6 +43,15 @@ export class Response {
this._result.push(result);
}
addError(error: string) {
this._result.push(error);
this._isError = true;
}
isError() {
return this._isError;
}
result() {
return this._result.join('\n');
}
@@ -67,17 +80,20 @@ export class Response {
this._includeTabs = true;
}
async snapshot(): Promise<string> {
if (this._snapshot !== undefined)
return this._snapshot;
async finish() {
// All the async snapshotting post-action is happening here.
// Everything below should race against modal states.
if (this._includeSnapshot && this._context.currentTab())
this._snapshot = await this._context.currentTabOrDie().captureSnapshot();
else
this._snapshot = '';
return this._snapshot;
this._tabSnapshot = await this._context.currentTabOrDie().captureSnapshot();
for (const tab of this._context.tabs())
await tab.updateTitle();
}
async serialize(): Promise<{ content: (TextContent | ImageContent)[] }> {
tabSnapshot(): TabSnapshot | undefined {
return this._tabSnapshot;
}
serialize(): { content: (TextContent | ImageContent)[], isError?: boolean } {
const response: string[] = [];
// Start with command result.
@@ -98,12 +114,16 @@ ${this._code.join('\n')}
// List browser tabs.
if (this._includeSnapshot || this._includeTabs)
response.push(...(await this._context.listTabsMarkdown(this._includeTabs)));
response.push(...renderTabsMarkdown(this._context.tabs(), this._includeTabs));
// Add snapshot if provided.
const snapshot = await this.snapshot();
if (snapshot)
response.push(snapshot, '');
if (this._tabSnapshot?.modalStates.length) {
response.push(...renderModalStates(this._context, this._tabSnapshot.modalStates));
response.push('');
} else if (this._tabSnapshot) {
response.push(renderTabSnapshot(this._tabSnapshot));
response.push('');
}
// Main response part
const content: (TextContent | ImageContent)[] = [
@@ -116,6 +136,66 @@ ${this._code.join('\n')}
content.push({ type: 'image', data: image.data.toString('base64'), mimeType: image.contentType });
}
return { content };
return { content, isError: this._isError };
}
}
function renderTabSnapshot(tabSnapshot: TabSnapshot): string {
const lines: string[] = [];
if (tabSnapshot.consoleMessages.length) {
lines.push(`### New console messages`);
for (const message of tabSnapshot.consoleMessages)
lines.push(`- ${trim(message.toString(), 100)}`);
lines.push('');
}
if (tabSnapshot.downloads.length) {
lines.push(`### Downloads`);
for (const entry of tabSnapshot.downloads) {
if (entry.finished)
lines.push(`- Downloaded file ${entry.download.suggestedFilename()} to ${entry.outputFile}`);
else
lines.push(`- Downloading file ${entry.download.suggestedFilename()} ...`);
}
lines.push('');
}
lines.push(`### Page state`);
lines.push(`- Page URL: ${tabSnapshot.url}`);
lines.push(`- Page Title: ${tabSnapshot.title}`);
lines.push(`- Page Snapshot:`);
lines.push('```yaml');
lines.push(tabSnapshot.ariaSnapshot);
lines.push('```');
return lines.join('\n');
}
function renderTabsMarkdown(tabs: Tab[], force: boolean = false): string[] {
if (tabs.length === 1 && !force)
return [];
if (!tabs.length) {
return [
'### Open tabs',
'No open tabs. Use the "browser_navigate" tool to navigate to a page first.',
'',
];
}
const lines: string[] = ['### Open tabs'];
for (let i = 0; i < tabs.length; i++) {
const tab = tabs[i];
const current = tab.isCurrentTab() ? ' (current)' : '';
lines.push(`- ${i}:${current} [${tab.lastTitle()}] (${tab.page.url()})`);
}
lines.push('');
return lines;
}
function trim(text: string, maxLength: number) {
if (text.length <= maxLength)
return text;
return text.slice(0, maxLength) + '...';
}

View File

@@ -17,76 +17,160 @@
import fs from 'fs';
import path from 'path';
import { outputFile } from './config.js';
import { Response } from './response.js';
import type { FullConfig } from './config.js';
import { logUnhandledError } from './log.js';
import { outputFile } from './config.js';
let sessionOrdinal = 0;
import type { FullConfig } from './config.js';
import type * as actions from './actions.js';
import type { Tab, TabSnapshot } from './tab.js';
type LogEntry = {
timestamp: number;
toolCall?: {
toolName: string;
toolArgs: Record<string, any>;
result: string;
isError?: boolean;
};
userAction?: actions.Action;
code: string;
tabSnapshot?: TabSnapshot;
};
export class SessionLog {
private _folder: string;
private _file: string;
private _ordinal = 0;
private _pendingEntries: LogEntry[] = [];
private _sessionFileQueue = Promise.resolve();
private _flushEntriesTimeout: NodeJS.Timeout | undefined;
constructor(sessionFolder: string) {
this._folder = sessionFolder;
this._file = path.join(this._folder, 'session.md');
}
static async create(config: FullConfig): Promise<SessionLog> {
const sessionFolder = await outputFile(config, `session-${(++sessionOrdinal).toString().padStart(3, '0')}`);
static async create(config: FullConfig, rootPath: string | undefined): Promise<SessionLog> {
const sessionFolder = await outputFile(config, rootPath, `session-${Date.now()}`);
await fs.promises.mkdir(sessionFolder, { recursive: true });
// eslint-disable-next-line no-console
console.error(`Session: ${sessionFolder}`);
return new SessionLog(sessionFolder);
}
async log(response: Response) {
const prefix = `${(++this._ordinal).toString().padStart(3, '0')}`;
const lines: string[] = [
`### Tool: ${response.toolName}`,
``,
`- Args`,
'```json',
JSON.stringify(response.toolArgs, null, 2),
'```',
];
if (response.result()) {
lines.push(
`- Result`,
'```',
response.result(),
'```');
logResponse(response: Response) {
const entry: LogEntry = {
timestamp: performance.now(),
toolCall: {
toolName: response.toolName,
toolArgs: response.toolArgs,
result: response.result(),
isError: response.isError(),
},
code: response.code(),
tabSnapshot: response.tabSnapshot(),
};
this._appendEntry(entry);
}
logUserAction(action: actions.Action, tab: Tab, code: string, isUpdate: boolean) {
code = code.trim();
if (isUpdate) {
const lastEntry = this._pendingEntries[this._pendingEntries.length - 1];
if (lastEntry.userAction?.name === action.name) {
lastEntry.userAction = action;
lastEntry.code = code;
return;
}
}
if (action.name === 'navigate') {
// Already logged at this location.
const lastEntry = this._pendingEntries[this._pendingEntries.length - 1];
if (lastEntry?.tabSnapshot?.url === action.url)
return;
}
const entry: LogEntry = {
timestamp: performance.now(),
userAction: action,
code,
tabSnapshot: {
url: tab.page.url(),
title: '',
ariaSnapshot: action.ariaSnapshot || '',
modalStates: [],
consoleMessages: [],
downloads: [],
},
};
this._appendEntry(entry);
}
private _appendEntry(entry: LogEntry) {
this._pendingEntries.push(entry);
if (this._flushEntriesTimeout)
clearTimeout(this._flushEntriesTimeout);
this._flushEntriesTimeout = setTimeout(() => this._flushEntries(), 1000);
}
private async _flushEntries() {
clearTimeout(this._flushEntriesTimeout);
const entries = this._pendingEntries;
this._pendingEntries = [];
const lines: string[] = [''];
for (const entry of entries) {
const ordinal = (++this._ordinal).toString().padStart(3, '0');
if (entry.toolCall) {
lines.push(
`### Tool call: ${entry.toolCall.toolName}`,
`- Args`,
'```json',
JSON.stringify(entry.toolCall.toolArgs, null, 2),
'```',
);
if (entry.toolCall.result) {
lines.push(
entry.toolCall.isError ? `- Error` : `- Result`,
'```',
entry.toolCall.result,
'```',
);
}
}
if (entry.userAction) {
const actionData = { ...entry.userAction } as any;
delete actionData.ariaSnapshot;
delete actionData.selector;
delete actionData.signals;
lines.push(
`### User action: ${entry.userAction.name}`,
`- Args`,
'```json',
JSON.stringify(actionData, null, 2),
'```',
);
}
if (entry.code) {
lines.push(
`- Code`,
'```js',
entry.code,
'```');
}
if (entry.tabSnapshot) {
const fileName = `${ordinal}.snapshot.yml`;
fs.promises.writeFile(path.join(this._folder, fileName), entry.tabSnapshot.ariaSnapshot).catch(logUnhandledError);
lines.push(`- Snapshot: ${fileName}`);
}
lines.push('', '');
}
if (response.code()) {
lines.push(
`- Code`,
'```js',
response.code(),
'```');
}
const snapshot = await response.snapshot();
if (snapshot) {
const fileName = `${prefix}.snapshot.yml`;
await fs.promises.writeFile(path.join(this._folder, fileName), snapshot);
lines.push(`- Snapshot: ${fileName}`);
}
for (const image of response.images()) {
const fileName = `${prefix}.screenshot.${extension(image.contentType)}`;
await fs.promises.writeFile(path.join(this._folder, fileName), image.data);
lines.push(`- Screenshot: ${fileName}`);
}
lines.push('', '');
await fs.promises.appendFile(this._file, lines.join('\n'));
this._sessionFileQueue = this._sessionFileQueue.then(() => fs.promises.appendFile(this._file, lines.join('\n')));
}
}
function extension(contentType: string): 'jpg' | 'png' {
if (contentType === 'image/jpeg')
return 'jpg';
return 'png';
}

View File

@@ -20,7 +20,6 @@ import { callOnPageNoTrace, waitForCompletion } from './tools/utils.js';
import { logUnhandledError } from './log.js';
import { ManualPromise } from './manualPromise.js';
import { ModalState } from './tools/tool.js';
import { outputFile } from './config.js';
import type { Context } from './context.js';
@@ -36,9 +35,19 @@ export type TabEventsInterface = {
[TabEvents.modalState]: [modalState: ModalState];
};
export type TabSnapshot = {
url: string;
title: string;
ariaSnapshot: string;
modalStates: ModalState[];
consoleMessages: ConsoleMessage[];
downloads: { download: playwright.Download, finished: boolean, outputFile: string }[];
};
export class Tab extends EventEmitter<TabEventsInterface> {
readonly context: Context;
readonly page: playwright.Page;
private _lastTitle = 'about:blank';
private _consoleMessages: ConsoleMessage[] = [];
private _recentConsoleMessages: ConsoleMessage[] = [];
private _requests: Map<playwright.Request, playwright.Response | null> = new Map();
@@ -69,6 +78,11 @@ export class Tab extends EventEmitter<TabEventsInterface> {
});
page.setDefaultNavigationTimeout(60000);
page.setDefaultTimeout(5000);
(page as any)[tabSymbol] = this;
}
static forPage(page: playwright.Page): Tab | undefined {
return (page as any)[tabSymbol];
}
modalStates(): ModalState[] {
@@ -85,14 +99,7 @@ export class Tab extends EventEmitter<TabEventsInterface> {
}
modalStatesMarkdown(): string[] {
const result: string[] = ['### Modal state'];
if (this._modalStates.length === 0)
result.push('- There is no modal state present');
for (const state of this._modalStates) {
const tool = this.context.tools.filter(tool => 'clearsModalState' in tool).find(tool => tool.clearsModalState === state.type);
result.push(`- [${state.description}]: can be handled by the "${tool?.schema.name}" tool`);
}
return result;
return renderModalStates(this.context, this.modalStates());
}
private _dialogShown(dialog: playwright.Dialog) {
@@ -107,7 +114,7 @@ export class Tab extends EventEmitter<TabEventsInterface> {
const entry = {
download,
finished: false,
outputFile: await outputFile(this.context.config, download.suggestedFilename())
outputFile: await this.context.outputFile(download.suggestedFilename())
};
this._downloads.push(entry);
await download.saveAs(entry.outputFile);
@@ -130,8 +137,18 @@ export class Tab extends EventEmitter<TabEventsInterface> {
this._onPageClose(this);
}
async title(): Promise<string> {
return await callOnPageNoTrace(this.page, page => page.title());
async updateTitle() {
await this._raceAgainstModalStates(async () => {
this._lastTitle = await callOnPageNoTrace(this.page, page => page.title());
});
}
lastTitle(): string {
return this._lastTitle;
}
isCurrentTab(): boolean {
return this === this.context.currentTab();
}
async waitForLoadState(state: 'load', options?: { timeout?: number }): Promise<void> {
@@ -175,71 +192,50 @@ export class Tab extends EventEmitter<TabEventsInterface> {
return this._requests;
}
private _takeRecentConsoleMarkdown(): string[] {
if (!this._recentConsoleMessages.length)
return [];
const result = this._recentConsoleMessages.map(message => {
return `- ${trim(message.toString(), 100)}`;
});
return [`### New console messages`, ...result, ''];
}
private _listDownloadsMarkdown(): string[] {
if (!this._downloads.length)
return [];
const result: string[] = ['### Downloads'];
for (const entry of this._downloads) {
if (entry.finished)
result.push(`- Downloaded file ${entry.download.suggestedFilename()} to ${entry.outputFile}`);
else
result.push(`- Downloading file ${entry.download.suggestedFilename()} ...`);
}
result.push('');
return result;
}
async captureSnapshot(): Promise<string> {
const result: string[] = [];
if (this.modalStates().length) {
result.push(...this.modalStatesMarkdown());
return result.join('\n');
}
result.push(...this._takeRecentConsoleMarkdown());
result.push(...this._listDownloadsMarkdown());
await this._raceAgainstModalStates(async () => {
async captureSnapshot(): Promise<TabSnapshot> {
let tabSnapshot: TabSnapshot | undefined;
const modalStates = await this._raceAgainstModalStates(async () => {
const snapshot = await (this.page as PageEx)._snapshotForAI();
result.push(
`### Page state`,
`- Page URL: ${this.page.url()}`,
`- Page Title: ${await this.page.title()}`,
`- Page Snapshot:`,
'```yaml',
snapshot,
'```',
);
tabSnapshot = {
url: this.page.url(),
title: await this.page.title(),
ariaSnapshot: snapshot,
modalStates: [],
consoleMessages: [],
downloads: this._downloads,
};
});
return result.join('\n');
if (tabSnapshot) {
// Assign console message late so that we did not lose any to modal state.
tabSnapshot.consoleMessages = this._recentConsoleMessages;
this._recentConsoleMessages = [];
}
return tabSnapshot ?? {
url: this.page.url(),
title: '',
ariaSnapshot: '',
modalStates,
consoleMessages: [],
downloads: [],
};
}
private _javaScriptBlocked(): boolean {
return this._modalStates.some(state => state.type === 'dialog');
}
private async _raceAgainstModalStates(action: () => Promise<void>): Promise<ModalState | undefined> {
private async _raceAgainstModalStates(action: () => Promise<void>): Promise<ModalState[]> {
if (this.modalStates().length)
return this.modalStates()[0];
return this.modalStates();
const promise = new ManualPromise<ModalState>();
const listener = (modalState: ModalState) => promise.resolve(modalState);
const promise = new ManualPromise<ModalState[]>();
const listener = (modalState: ModalState) => promise.resolve([modalState]);
this.once(TabEvents.modalState, listener);
return await Promise.race([
action().then(() => {
this.off(TabEvents.modalState, listener);
return undefined;
return [];
}),
promise,
]);
@@ -303,8 +299,15 @@ function pageErrorToConsoleMessage(errorOrValue: Error | any): ConsoleMessage {
};
}
function trim(text: string, maxLength: number) {
if (text.length <= maxLength)
return text;
return text.slice(0, maxLength) + '...';
export function renderModalStates(context: Context, modalStates: ModalState[]): string[] {
const result: string[] = ['### Modal state'];
if (modalStates.length === 0)
result.push('- There is no modal state present');
for (const state of modalStates) {
const tool = context.tools.filter(tool => 'clearsModalState' in tool).find(tool => tool.clearsModalState === state.type);
result.push(`- [${state.description}]: can be handled by the "${tool?.schema.name}" tool`);
}
return result;
}
const tabSymbol = Symbol('tabSymbol');

View File

@@ -49,7 +49,6 @@ const resize = defineTabTool({
},
handle: async (tab, params, response) => {
response.addCode(`// Resize browser window to ${params.width}x${params.height}`);
response.addCode(`await page.setViewportSize({ width: ${params.width}, height: ${params.height} });`);
await tab.waitForCompletion(async () => {

View File

@@ -37,7 +37,6 @@ const uploadFile = defineTabTool({
if (!modalState)
throw new Error('No file chooser visible');
response.addCode(`// Select files for upload`);
response.addCode(`await fileChooser.setFiles(${JSON.stringify(params.paths)})`);
tab.clearModalState(modalState);

View File

@@ -67,11 +67,9 @@ const type = defineTabTool({
await tab.waitForCompletion(async () => {
if (params.slowly) {
response.setIncludeSnapshot();
response.addCode(`// Press "${params.text}" sequentially into "${params.element}"`);
response.addCode(`await page.${await generateLocator(locator)}.pressSequentially(${javascript.quote(params.text)});`);
await locator.pressSequentially(params.text);
} else {
response.addCode(`// Fill "${params.text}" into "${params.element}"`);
response.addCode(`await page.${await generateLocator(locator)}.fill(${javascript.quote(params.text)});`);
await locator.fill(params.text);
}

View File

@@ -35,7 +35,6 @@ const navigate = defineTool({
await tab.navigate(params.url);
response.setIncludeSnapshot();
response.addCode(`// Navigate to ${params.url}`);
response.addCode(`await page.goto('${params.url}');`);
},
});
@@ -53,7 +52,6 @@ const goBack = defineTabTool({
handle: async (tab, params, response) => {
await tab.page.goBack();
response.setIncludeSnapshot();
response.addCode(`// Navigate back`);
response.addCode(`await page.goBack();`);
},
});
@@ -70,7 +68,6 @@ const goForward = defineTabTool({
handle: async (tab, params, response) => {
await tab.page.goForward();
response.setIncludeSnapshot();
response.addCode(`// Navigate forward`);
response.addCode(`await page.goForward();`);
},
});

View File

@@ -18,7 +18,6 @@ import { z } from 'zod';
import { defineTabTool } from './tool.js';
import * as javascript from '../javascript.js';
import { outputFile } from '../config.js';
const pdfSchema = z.object({
filename: z.string().optional().describe('File name to save the pdf to. Defaults to `page-{timestamp}.pdf` if not specified.'),
@@ -36,8 +35,7 @@ const pdf = defineTabTool({
},
handle: async (tab, params, response) => {
const fileName = await outputFile(tab.context.config, params.filename ?? `page-${new Date().toISOString()}.pdf`);
response.addCode(`// Save page as ${fileName}`);
const fileName = await tab.context.outputFile(params.filename ?? `page-${new Date().toISOString()}.pdf`);
response.addCode(`await page.pdf(${javascript.formatObject({ path: fileName })});`);
response.addResult(`Saved page as ${fileName}`);
await tab.page.pdf({ path: fileName });

View File

@@ -18,13 +18,12 @@ import { z } from 'zod';
import { defineTabTool } from './tool.js';
import * as javascript from '../javascript.js';
import { outputFile } from '../config.js';
import { generateLocator } from './utils.js';
import type * as playwright from 'playwright';
const screenshotSchema = z.object({
raw: z.boolean().optional().describe('Whether to return without compression (in PNG format). Default is false, which returns a JPEG image.'),
type: z.enum(['png', 'jpeg']).default('png').describe('Image format for the screenshot. Default is png.'),
filename: z.string().optional().describe('File name to save the screenshot to. Defaults to `page-{timestamp}.{png|jpeg}` if not specified.'),
element: z.string().optional().describe('Human-readable element description used to obtain permission to screenshot the element. If not provided, the screenshot will be taken of viewport. If element is provided, ref must be provided too.'),
ref: z.string().optional().describe('Exact target element reference from the page snapshot. If not provided, the screenshot will be taken of viewport. If ref is provided, element must be provided too.'),
@@ -52,11 +51,11 @@ const screenshot = defineTabTool({
},
handle: async (tab, params, response) => {
const fileType = params.raw ? 'png' : 'jpeg';
const fileName = await outputFile(tab.context.config, params.filename ?? `page-${new Date().toISOString()}.${fileType}`);
const fileType = params.type || 'png';
const fileName = await tab.context.outputFile(params.filename ?? `page-${new Date().toISOString()}.${fileType}`);
const options: playwright.PageScreenshotOptions = {
type: fileType,
quality: fileType === 'png' ? undefined : 50,
quality: fileType === 'png' ? undefined : 90,
scale: 'css',
path: fileName,
...(params.fullPage !== undefined && { fullPage: params.fullPage })

View File

@@ -63,13 +63,11 @@ const click = defineTabTool({
const button = params.button;
const buttonAttr = button ? `{ button: '${button}' }` : '';
if (params.doubleClick) {
response.addCode(`// Double click ${params.element}`);
if (params.doubleClick)
response.addCode(`await page.${await generateLocator(locator)}.dblclick(${buttonAttr});`);
} else {
response.addCode(`// Click ${params.element}`);
else
response.addCode(`await page.${await generateLocator(locator)}.click(${buttonAttr});`);
}
await tab.waitForCompletion(async () => {
if (params.doubleClick)
@@ -151,7 +149,6 @@ const selectOption = defineTabTool({
response.setIncludeSnapshot();
const locator = await tab.refLocator(params);
response.addCode(`// Select options [${params.values.join(', ')}] in ${params.element}`);
response.addCode(`await page.${await generateLocator(locator)}.selectOption(${javascript.formatObject(params.values)});`);
await tab.waitForCompletion(async () => {

View File

@@ -60,10 +60,11 @@ export function defineTabTool<Input extends z.Schema>(tool: TabTool<Input>): Too
const tab = context.currentTabOrDie();
const modalStates = tab.modalStates().map(state => state.type);
if (tool.clearsModalState && !modalStates.includes(tool.clearsModalState))
throw new Error(`The tool "${tool.schema.name}" can only be used when there is related modal state present.\n` + tab.modalStatesMarkdown().join('\n'));
if (!tool.clearsModalState && modalStates.length)
throw new Error(`Tool "${tool.schema.name}" does not handle the modal state.\n` + tab.modalStatesMarkdown().join('\n'));
return tool.handle(tab, params, response);
response.addError(`Error: The tool "${tool.schema.name}" can only be used when there is related modal state present.\n` + tab.modalStatesMarkdown().join('\n'));
else if (!tool.clearsModalState && modalStates.length)
response.addError(`Error: Tool "${tool.schema.name}" does not handle the modal state.\n` + tab.modalStatesMarkdown().join('\n'));
else
return tool.handle(tab, params, response);
},
};
}

View File

@@ -71,14 +71,6 @@ export async function waitForCompletion<R>(tab: Tab, callback: () => Promise<R>)
}
}
export function sanitizeForFilePath(s: string) {
const sanitize = (s: string) => s.replace(/[\x00-\x2C\x2E-\x2F\x3A-\x40\x5B-\x60\x7B-\x7F]+/g, '-');
const separator = s.lastIndexOf('.');
if (separator === -1)
return sanitize(s);
return sanitize(s.substring(0, separator)) + '.' + sanitize(s.substring(separator + 1));
}
export async function generateLocator(locator: playwright.Locator): Promise<string> {
try {
const { resolvedSelector } = await (locator as any)._resolveSelector();

29
src/utils.ts Normal file
View File

@@ -0,0 +1,29 @@
/**
* 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 crypto from 'crypto';
export function createHash(data: string): string {
return crypto.createHash('sha256').update(data).digest('hex').slice(0, 7);
}
export function sanitizeForFilePath(s: string) {
const sanitize = (s: string) => s.replace(/[\x00-\x2C\x2E-\x2F\x3A-\x40\x5B-\x60\x7B-\x7F]+/g, '-');
const separator = s.lastIndexOf('.');
if (separator === -1)
return sanitize(s);
return sanitize(s.substring(0, separator)) + '.' + sanitize(s.substring(separator + 1));
}

View File

@@ -25,7 +25,9 @@ test('cdp server', async ({ cdpServer, startClient, server }) => {
expect(await client.callTool({
name: 'browser_navigate',
arguments: { url: server.HELLO_WORLD },
})).toContainTextContent(`- generic [active] [ref=e1]: Hello, world!`);
})).toHaveResponse({
pageState: expect.stringContaining(`- generic [active] [ref=e1]: Hello, world!`),
});
});
test('cdp server reuse tab', async ({ cdpServer, startClient, server }) => {
@@ -41,18 +43,21 @@ test('cdp server reuse tab', async ({ cdpServer, startClient, server }) => {
element: 'Hello, world!',
ref: 'f0',
},
})).toHaveTextContent(`Error: No open pages available. Use the \"browser_navigate\" tool to navigate to a page first.`);
})).toHaveResponse({
result: `Error: No open pages available. Use the "browser_navigate" tool to navigate to a page first.`,
isError: true,
});
expect(await client.callTool({
name: 'browser_snapshot',
})).toHaveTextContent(`### Page state
- Page URL: ${server.HELLO_WORLD}
})).toHaveResponse({
pageState: expect.stringContaining(`- Page URL: ${server.HELLO_WORLD}
- Page Title: Title
- Page Snapshot:
\`\`\`yaml
- generic [active] [ref=e1]: Hello, world!
\`\`\`
`);
\`\`\``),
});
});
test('should throw connection error and allow re-connecting', async ({ cdpServer, startClient, server }) => {
@@ -66,12 +71,17 @@ test('should throw connection error and allow re-connecting', async ({ cdpServer
expect(await client.callTool({
name: 'browser_navigate',
arguments: { url: server.PREFIX },
})).toContainTextContent(`Error: browserType.connectOverCDP: connect ECONNREFUSED`);
})).toHaveResponse({
result: expect.stringContaining(`Error: browserType.connectOverCDP: connect ECONNREFUSED`),
isError: true,
});
await cdpServer.start();
expect(await client.callTool({
name: 'browser_navigate',
arguments: { url: server.PREFIX },
})).toContainTextContent(`- generic [active] [ref=e1]: Hello, world!`);
})).toHaveResponse({
pageState: expect.stringContaining(`- generic [active] [ref=e1]: Hello, world!`),
});
});
// NOTE: Can be removed when we drop Node.js 18 support and changed to import.meta.filename.

View File

@@ -33,21 +33,10 @@ test('browser_click', async ({ client, server, mcpBrowser }) => {
element: 'Submit button',
ref: 'e2',
},
})).toHaveTextContent(`
### Ran Playwright code
\`\`\`js
// Click Submit button
await page.getByRole('button', { name: 'Submit' }).click();
\`\`\`
### Page state
- Page URL: ${server.PREFIX}
- Page Title: Title
- Page Snapshot:
\`\`\`yaml
- button "Submit" ${mcpBrowser !== 'webkit' || process.platform === 'linux' ? '[active] ' : ''}[ref=e2]
\`\`\`
`);
})).toHaveResponse({
code: `await page.getByRole('button', { name: 'Submit' }).click();`,
pageState: expect.stringContaining(`- button "Submit" ${mcpBrowser !== 'webkit' || process.platform === 'linux' ? '[active] ' : ''}[ref=e2]`),
});
});
test('browser_click (double)', async ({ client, server }) => {
@@ -73,21 +62,10 @@ test('browser_click (double)', async ({ client, server }) => {
ref: 'e2',
doubleClick: true,
},
})).toHaveTextContent(`
### Ran Playwright code
\`\`\`js
// Double click Click me
await page.getByRole('heading', { name: 'Click me' }).dblclick();
\`\`\`
### Page state
- Page URL: ${server.PREFIX}
- Page Title: Title
- Page Snapshot:
\`\`\`yaml
- heading "Double clicked" [level=1] [ref=e3]
\`\`\`
`);
})).toHaveResponse({
code: `await page.getByRole('heading', { name: 'Click me' }).dblclick();`,
pageState: expect.stringContaining(`- heading "Double clicked" [level=1] [ref=e3]`),
});
});
test('browser_click (right)', async ({ client, server }) => {
@@ -114,6 +92,8 @@ test('browser_click (right)', async ({ client, server }) => {
button: 'right',
},
});
expect(result).toContainTextContent(`await page.getByRole('button', { name: 'Menu' }).click({ button: 'right' });`);
expect(result).toContainTextContent(`- button "Right clicked"`);
expect(result).toHaveResponse({
code: `await page.getByRole('button', { name: 'Menu' }).click({ button: 'right' });`,
pageState: expect.stringContaining(`- button "Right clicked"`),
});
});

View File

@@ -37,7 +37,9 @@ test('config user data dir', async ({ startClient, server, mcpMode }, testInfo)
expect(await client.callTool({
name: 'browser_navigate',
arguments: { url: server.PREFIX },
})).toContainTextContent(`Hello, world!`);
})).toHaveResponse({
pageState: expect.stringContaining(`Hello, world!`),
});
const files = await fs.promises.readdir(config.browser!.userDataDir!);
expect(files.length).toBeGreaterThan(0);
@@ -58,7 +60,9 @@ test.describe(() => {
expect(await client.callTool({
name: 'browser_navigate',
arguments: { url: 'data:text/html,<script>document.title = navigator.userAgent</script>' },
})).toContainTextContent(`Firefox`);
})).toHaveResponse({
pageState: expect.stringContaining(`Firefox`),
});
});
});

View File

@@ -37,11 +37,10 @@ test('browser_console_messages', async ({ client, server }) => {
const resource = await client.callTool({
name: 'browser_console_messages',
});
expect(resource).toHaveTextContent([
'### Result',
`[LOG] Hello, world! @ ${server.PREFIX}:4`,
`[ERROR] Error @ ${server.PREFIX}:5`,
].join('\n'));
expect(resource).toHaveResponse({
result: `[LOG] Hello, world! @ ${server.PREFIX}:4
[ERROR] Error @ ${server.PREFIX}:5`,
});
});
test('browser_console_messages (page error)', async ({ client, server }) => {
@@ -64,8 +63,12 @@ test('browser_console_messages (page error)', async ({ client, server }) => {
const resource = await client.callTool({
name: 'browser_console_messages',
});
expect(resource).toHaveTextContent(/Error: Error in script/);
expect(resource).toHaveTextContent(new RegExp(server.PREFIX));
expect(resource).toHaveResponse({
result: expect.stringContaining(`Error: Error in script`),
});
expect(resource).toHaveResponse({
result: expect.stringContaining(server.PREFIX),
});
});
test('recent console messages', async ({ client, server }) => {
@@ -91,7 +94,7 @@ test('recent console messages', async ({ client, server }) => {
},
});
expect(response).toContainTextContent(`
### New console messages
- [LOG] Hello, world! @`);
expect(response).toHaveResponse({
consoleMessages: expect.stringContaining(`- [LOG] Hello, world! @`),
});
});

View File

@@ -20,22 +20,15 @@ test('browser_navigate', async ({ client, server }) => {
expect(await client.callTool({
name: 'browser_navigate',
arguments: { url: server.HELLO_WORLD },
})).toHaveTextContent(`
### Ran Playwright code
\`\`\`js
// Navigate to ${server.HELLO_WORLD}
await page.goto('${server.HELLO_WORLD}');
\`\`\`
### Page state
- Page URL: ${server.HELLO_WORLD}
})).toHaveResponse({
code: `await page.goto('${server.HELLO_WORLD}');`,
pageState: `- Page URL: ${server.HELLO_WORLD}
- Page Title: Title
- Page Snapshot:
\`\`\`yaml
- generic [active] [ref=e1]: Hello, world!
\`\`\`
`
);
\`\`\``,
});
});
test('browser_select_option', async ({ client, server }) => {
@@ -59,23 +52,17 @@ test('browser_select_option', async ({ client, server }) => {
ref: 'e2',
values: ['bar'],
},
})).toHaveTextContent(`
### Ran Playwright code
\`\`\`js
// Select options [bar] in Select
await page.getByRole('combobox').selectOption(['bar']);
\`\`\`
### Page state
- Page URL: ${server.PREFIX}
})).toHaveResponse({
code: `await page.getByRole('combobox').selectOption(['bar']);`,
pageState: `- Page URL: ${server.PREFIX}
- Page Title: Title
- Page Snapshot:
\`\`\`yaml
- combobox [ref=e2]:
- option "Foo"
- option "Bar" [selected]
\`\`\`
`);
\`\`\``,
});
});
test('browser_select_option (multiple)', async ({ client, server }) => {
@@ -100,24 +87,14 @@ test('browser_select_option (multiple)', async ({ client, server }) => {
ref: 'e2',
values: ['bar', 'baz'],
},
})).toHaveTextContent(`
### Ran Playwright code
\`\`\`js
// Select options [bar, baz] in Select
await page.getByRole('listbox').selectOption(['bar', 'baz']);
\`\`\`
### Page state
- Page URL: ${server.PREFIX}
- Page Title: Title
- Page Snapshot:
\`\`\`yaml
})).toHaveResponse({
code: `await page.getByRole('listbox').selectOption(['bar', 'baz']);`,
pageState: expect.stringContaining(`
- listbox [ref=e2]:
- option "Foo" [ref=e3]
- option "Bar" [selected] [ref=e4]
- option "Baz" [selected] [ref=e5]
\`\`\`
`);
- option "Baz" [selected] [ref=e5]`),
});
});
test('browser_resize', async ({ client, server }) => {
@@ -141,12 +118,12 @@ test('browser_resize', async ({ client, server }) => {
height: 780,
},
});
expect(response).toContainTextContent(`### Ran Playwright code
\`\`\`js
// Resize browser window to 390x780
await page.setViewportSize({ width: 390, height: 780 });
\`\`\``);
await expect.poll(() => client.callTool({ name: 'browser_snapshot' })).toContainTextContent('Window size: 390x780');
expect(response).toHaveResponse({
code: `await page.setViewportSize({ width: 390, height: 780 });`,
});
await expect.poll(() => client.callTool({ name: 'browser_snapshot' })).toHaveResponse({
pageState: expect.stringContaining(`Window size: 390x780`),
});
});
test('old locator error message', async ({ client, server }) => {
@@ -165,10 +142,11 @@ test('old locator error message', async ({ client, server }) => {
arguments: {
url: server.PREFIX,
},
})).toContainTextContent(`
})).toHaveResponse({
pageState: expect.stringContaining(`
- button "Button 1" [ref=e2]
- button "Button 2" [ref=e3]
`.trim());
- button "Button 2" [ref=e3]`),
});
await client.callTool({
name: 'browser_click',
@@ -184,7 +162,10 @@ test('old locator error message', async ({ client, server }) => {
element: 'Button 2',
ref: 'e3',
},
})).toContainTextContent('Ref e3 not found in the current page snapshot. Try capturing new snapshot.');
})).toHaveResponse({
result: expect.stringContaining(`Ref e3 not found in the current page snapshot. Try capturing new snapshot.`),
isError: true,
});
});
test('visibility: hidden > visible should be shown', { annotation: { type: 'issue', description: 'https://github.com/microsoft/playwright-mcp/issues/535' } }, async ({ client, server }) => {
@@ -203,5 +184,7 @@ test('visibility: hidden > visible should be shown', { annotation: { type: 'issu
expect(await client.callTool({
name: 'browser_snapshot'
})).toContainTextContent('- button "Button"');
})).toHaveResponse({
pageState: expect.stringContaining(`- button "Button"`),
});
});

View File

@@ -39,5 +39,7 @@ test('--device should work', async ({ startClient, server, mcpMode }) => {
arguments: {
url: server.PREFIX,
},
})).toContainTextContent(`393x659`);
})).toHaveResponse({
pageState: expect.stringContaining(`393x659`),
});
});

View File

@@ -21,7 +21,9 @@ test('alert dialog', async ({ client, server }) => {
expect(await client.callTool({
name: 'browser_navigate',
arguments: { url: server.PREFIX },
})).toContainTextContent('- button "Button" [ref=e2]');
})).toHaveResponse({
pageState: expect.stringContaining(`- button "Button" [ref=e2]`),
});
expect(await client.callTool({
name: 'browser_click',
@@ -29,25 +31,31 @@ test('alert dialog', async ({ client, server }) => {
element: 'Button',
ref: 'e2',
},
})).toHaveTextContent(`### Ran Playwright code
\`\`\`js
// Click Button
await page.getByRole('button', { name: 'Button' }).click();
\`\`\`
})).toHaveResponse({
code: `await page.getByRole('button', { name: 'Button' }).click();`,
modalState: `- ["alert" dialog with message "Alert"]: can be handled by the "browser_handle_dialog" tool`,
});
### Modal state
- ["alert" dialog with message "Alert"]: can be handled by the "browser_handle_dialog" tool
`);
expect(await client.callTool({
name: 'browser_click',
arguments: {
element: 'Button',
ref: 'e2',
},
})).toHaveResponse({
code: undefined,
modalState: `- ["alert" dialog with message "Alert"]: can be handled by the "browser_handle_dialog" tool`,
});
const result = await client.callTool({
expect(await client.callTool({
name: 'browser_handle_dialog',
arguments: {
accept: true,
},
})).toHaveResponse({
modalState: undefined,
pageState: expect.stringContaining(`- button "Button"`),
});
expect(result).not.toContainTextContent('### Modal state');
expect(result).toContainTextContent(`Page Snapshot:`);
});
test('two alert dialogs', async ({ client, server }) => {
@@ -61,7 +69,9 @@ test('two alert dialogs', async ({ client, server }) => {
expect(await client.callTool({
name: 'browser_navigate',
arguments: { url: server.PREFIX },
})).toContainTextContent('- button "Button" [ref=e2]');
})).toHaveResponse({
pageState: expect.stringContaining(`- button "Button" [ref=e2]`),
});
expect(await client.callTool({
name: 'browser_click',
@@ -69,15 +79,10 @@ test('two alert dialogs', async ({ client, server }) => {
element: 'Button',
ref: 'e2',
},
})).toHaveTextContent(`### Ran Playwright code
\`\`\`js
// Click Button
await page.getByRole('button', { name: 'Button' }).click();
\`\`\`
### Modal state
- ["alert" dialog with message "Alert 1"]: can be handled by the "browser_handle_dialog" tool
`);
})).toHaveResponse({
code: `await page.getByRole('button', { name: 'Button' }).click();`,
modalState: expect.stringContaining(`- ["alert" dialog with message "Alert 1"]: can be handled by the "browser_handle_dialog" tool`),
});
const result = await client.callTool({
name: 'browser_handle_dialog',
@@ -86,9 +91,9 @@ await page.getByRole('button', { name: 'Button' }).click();
},
});
expect(result).toContainTextContent(`### Modal state
- ["alert" dialog with message "Alert 2"]: can be handled by the "browser_handle_dialog" tool
`);
expect(result).toHaveResponse({
modalState: expect.stringContaining(`- ["alert" dialog with message "Alert 2"]: can be handled by the "browser_handle_dialog" tool`),
});
const result2 = await client.callTool({
name: 'browser_handle_dialog',
@@ -97,7 +102,9 @@ await page.getByRole('button', { name: 'Button' }).click();
},
});
expect(result2).not.toContainTextContent('### Modal state');
expect(result2).not.toHaveResponse({
modalState: expect.stringContaining(`- ["alert" dialog with message "Alert 2"]: can be handled by the "browser_handle_dialog" tool`),
});
});
test('confirm dialog (true)', async ({ client, server }) => {
@@ -111,7 +118,9 @@ test('confirm dialog (true)', async ({ client, server }) => {
expect(await client.callTool({
name: 'browser_navigate',
arguments: { url: server.PREFIX },
})).toContainTextContent('- button "Button" [ref=e2]');
})).toHaveResponse({
pageState: expect.stringContaining(`- button "Button" [ref=e2]`),
});
expect(await client.callTool({
name: 'browser_click',
@@ -119,21 +128,19 @@ test('confirm dialog (true)', async ({ client, server }) => {
element: 'Button',
ref: 'e2',
},
})).toContainTextContent(`### Modal state
- ["confirm" dialog with message "Confirm"]: can be handled by the "browser_handle_dialog" tool`);
})).toHaveResponse({
modalState: expect.stringContaining(`- ["confirm" dialog with message "Confirm"]: can be handled by the "browser_handle_dialog" tool`),
});
const result = await client.callTool({
expect(await client.callTool({
name: 'browser_handle_dialog',
arguments: {
accept: true,
},
})).toHaveResponse({
modalState: undefined,
pageState: expect.stringContaining(`- generic [active] [ref=e1]: "true"`),
});
expect(result).not.toContainTextContent('### Modal state');
expect(result).toContainTextContent(`- Page Snapshot:
\`\`\`yaml
- generic [active] [ref=e1]: "true"
\`\`\``);
});
test('confirm dialog (false)', async ({ client, server }) => {
@@ -147,7 +154,9 @@ test('confirm dialog (false)', async ({ client, server }) => {
expect(await client.callTool({
name: 'browser_navigate',
arguments: { url: server.PREFIX },
})).toContainTextContent('- button "Button" [ref=e2]');
})).toHaveResponse({
pageState: expect.stringContaining(`- button "Button" [ref=e2]`),
});
expect(await client.callTool({
name: 'browser_click',
@@ -155,21 +164,19 @@ test('confirm dialog (false)', async ({ client, server }) => {
element: 'Button',
ref: 'e2',
},
})).toContainTextContent(`### Modal state
- ["confirm" dialog with message "Confirm"]: can be handled by the "browser_handle_dialog" tool
`);
})).toHaveResponse({
modalState: expect.stringContaining(`- ["confirm" dialog with message "Confirm"]: can be handled by the "browser_handle_dialog" tool`),
});
const result = await client.callTool({
expect(await client.callTool({
name: 'browser_handle_dialog',
arguments: {
accept: false,
},
})).toHaveResponse({
modalState: undefined,
pageState: expect.stringContaining(`- generic [active] [ref=e1]: "false"`),
});
expect(result).toContainTextContent(`- Page Snapshot:
\`\`\`yaml
- generic [active] [ref=e1]: "false"
\`\`\``);
});
test('prompt dialog', async ({ client, server }) => {
@@ -183,7 +190,9 @@ test('prompt dialog', async ({ client, server }) => {
expect(await client.callTool({
name: 'browser_navigate',
arguments: { url: server.PREFIX },
})).toContainTextContent('- button "Button" [ref=e2]');
})).toHaveResponse({
pageState: expect.stringContaining(`- button "Button" [ref=e2]`),
});
expect(await client.callTool({
name: 'browser_click',
@@ -191,9 +200,9 @@ test('prompt dialog', async ({ client, server }) => {
element: 'Button',
ref: 'e2',
},
})).toContainTextContent(`### Modal state
- ["prompt" dialog with message "Prompt"]: can be handled by the "browser_handle_dialog" tool
`);
})).toHaveResponse({
modalState: expect.stringContaining(`- ["prompt" dialog with message "Prompt"]: can be handled by the "browser_handle_dialog" tool`),
});
const result = await client.callTool({
name: 'browser_handle_dialog',
@@ -203,10 +212,9 @@ test('prompt dialog', async ({ client, server }) => {
},
});
expect(result).toContainTextContent(`- Page Snapshot:
\`\`\`yaml
- generic [active] [ref=e1]: Answer
\`\`\``);
expect(result).toHaveResponse({
pageState: expect.stringContaining(`- generic [active] [ref=e1]: Answer`),
});
});
test('alert dialog w/ race', async ({ client, server }) => {
@@ -214,7 +222,9 @@ test('alert dialog w/ race', async ({ client, server }) => {
expect(await client.callTool({
name: 'browser_navigate',
arguments: { url: server.PREFIX },
})).toContainTextContent('- button "Button" [ref=e2]');
})).toHaveResponse({
pageState: expect.stringContaining(`- button "Button" [ref=e2]`),
});
expect(await client.callTool({
name: 'browser_click',
@@ -222,15 +232,10 @@ test('alert dialog w/ race', async ({ client, server }) => {
element: 'Button',
ref: 'e2',
},
})).toHaveTextContent(`### Ran Playwright code
\`\`\`js
// Click Button
await page.getByRole('button', { name: 'Button' }).click();
\`\`\`
### Modal state
- ["alert" dialog with message "Alert"]: can be handled by the "browser_handle_dialog" tool
`);
})).toHaveResponse({
code: `await page.getByRole('button', { name: 'Button' }).click();`,
modalState: expect.stringContaining(`- ["alert" dialog with message "Alert"]: can be handled by the "browser_handle_dialog" tool`),
});
const result = await client.callTool({
name: 'browser_handle_dialog',
@@ -239,11 +244,12 @@ await page.getByRole('button', { name: 'Button' }).click();
},
});
expect(result).not.toContainTextContent('### Modal state');
expect(result).toContainTextContent(`### Page state
- Page URL: ${server.PREFIX}
expect(result).toHaveResponse({
modalState: undefined,
pageState: expect.stringContaining(`- Page URL: ${server.PREFIX}
- Page Title:
- Page Snapshot:
\`\`\`yaml
- button "Button"`);
- button "Button"`),
});
});

View File

@@ -20,15 +20,19 @@ test('browser_evaluate', async ({ client, server }) => {
expect(await client.callTool({
name: 'browser_navigate',
arguments: { url: server.HELLO_WORLD },
})).toContainTextContent(`- Page Title: Title`);
})).toHaveResponse({
pageState: expect.stringContaining(`- Page Title: Title`),
});
const result = await client.callTool({
expect(await client.callTool({
name: 'browser_evaluate',
arguments: {
function: '() => document.title',
},
})).toHaveResponse({
result: `"Title"`,
code: `await page.evaluate('() => document.title');`,
});
expect(result).toContainTextContent(`"Title"`);
});
test('browser_evaluate (element)', async ({ client, server }) => {
@@ -47,15 +51,19 @@ test('browser_evaluate (element)', async ({ client, server }) => {
element: 'body',
ref: 'e1',
},
})).toContainTextContent(`### Result
"red"`);
})).toHaveResponse({
result: `"red"`,
code: `await page.getByText('Hello, world!').evaluate('element => element.style.backgroundColor');`,
});
});
test('browser_evaluate (error)', async ({ client, server }) => {
expect(await client.callTool({
name: 'browser_navigate',
arguments: { url: server.HELLO_WORLD },
})).toContainTextContent(`- Page Title: Title`);
})).toHaveResponse({
pageState: expect.stringContaining(`- Page Title: Title`),
});
const result = await client.callTool({
name: 'browser_evaluate',

View File

@@ -26,22 +26,21 @@ test('browser_file_upload', async ({ client, server }, testInfo) => {
expect(await client.callTool({
name: 'browser_navigate',
arguments: { url: server.PREFIX },
})).toContainTextContent(`
\`\`\`yaml
- generic [active] [ref=e1]:
})).toHaveResponse({
pageState: expect.stringContaining(`- generic [active] [ref=e1]:
- button "Choose File" [ref=e2]
- button "Button" [ref=e3]
\`\`\``);
- button "Button" [ref=e3]`),
});
{
expect(await client.callTool({
name: 'browser_file_upload',
arguments: { paths: [] },
})).toHaveTextContent(`
Error: The tool "browser_file_upload" can only be used when there is related modal state present.
### Modal state
- There is no modal state present
`.trim());
})).toHaveResponse({
isError: true,
result: expect.stringContaining(`The tool "browser_file_upload" can only be used when there is related modal state present.`),
modalState: expect.stringContaining(`- There is no modal state present`),
});
}
expect(await client.callTool({
@@ -50,8 +49,9 @@ Error: The tool "browser_file_upload" can only be used when there is related mod
element: 'Textbox',
ref: 'e2',
},
})).toContainTextContent(`### Modal state
- [File chooser]: can be handled by the "browser_file_upload" tool`);
})).toHaveResponse({
modalState: expect.stringContaining(`- [File chooser]: can be handled by the "browser_file_upload" tool`),
});
const filePath = testInfo.outputPath('test.txt');
await fs.writeFile(filePath, 'Hello, world!');
@@ -64,7 +64,10 @@ Error: The tool "browser_file_upload" can only be used when there is related mod
},
});
expect(response).not.toContainTextContent('### Modal state');
expect(response).toHaveResponse({
code: expect.stringContaining(`await fileChooser.setFiles(`),
modalState: undefined,
});
}
{
@@ -76,7 +79,9 @@ Error: The tool "browser_file_upload" can only be used when there is related mod
},
});
expect(response).toContainTextContent('- [File chooser]: can be handled by the \"browser_file_upload\" tool');
expect(response).toHaveResponse({
modalState: `- [File chooser]: can be handled by the "browser_file_upload" tool`,
});
}
{
@@ -88,9 +93,10 @@ Error: The tool "browser_file_upload" can only be used when there is related mod
},
});
expect(response).toContainTextContent(`Error: Tool "browser_click" does not handle the modal state.
### Modal state
- [File chooser]: can be handled by the "browser_file_upload" tool`);
expect(response).toHaveResponse({
result: `Error: Tool "browser_click" does not handle the modal state.`,
modalState: expect.stringContaining(`- [File chooser]: can be handled by the "browser_file_upload" tool`),
});
}
});
@@ -105,7 +111,9 @@ test('clicking on download link emits download', async ({ startClient, server, m
expect(await client.callTool({
name: 'browser_navigate',
arguments: { url: server.PREFIX },
})).toContainTextContent('- link "Download" [ref=e2]');
})).toHaveResponse({
pageState: expect.stringContaining(`- link "Download" [ref=e2]`),
});
await client.callTool({
name: 'browser_click',
arguments: {
@@ -113,8 +121,9 @@ test('clicking on download link emits download', async ({ startClient, server, m
ref: 'e2',
},
});
await expect.poll(() => client.callTool({ name: 'browser_snapshot' })).toContainTextContent(`### Downloads
- Downloaded file test.txt to ${testInfo.outputPath('output', 'test.txt')}`);
await expect.poll(() => client.callTool({ name: 'browser_snapshot' })).toHaveResponse({
downloads: `- Downloaded file test.txt to ${testInfo.outputPath('output', 'test.txt')}`,
});
});
test('navigating to download link emits download', async ({ startClient, server, mcpBrowser, mcpMode }, testInfo) => {
@@ -136,5 +145,7 @@ test('navigating to download link emits download', async ({ startClient, server,
arguments: {
url: server.PREFIX + 'download',
},
})).toContainTextContent('### Downloads');
})).toHaveResponse({
downloads: expect.stringContaining(`- Downloaded file test.txt to`),
});
});

View File

@@ -22,6 +22,7 @@ import { chromium } from 'playwright';
import { test as baseTest, expect as baseExpect } from '@playwright/test';
import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js';
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
import { ListRootsRequestSchema } from '@modelcontextprotocol/sdk/types.js';
import { TestServer } from './testserver/index.ts';
import type { Config } from '../config';
@@ -41,7 +42,12 @@ type CDPServer = {
type TestFixtures = {
client: Client;
startClient: (options?: { clientName?: string, args?: string[], config?: Config }) => Promise<{ client: Client, stderr: () => string }>;
startClient: (options?: {
clientName?: string,
args?: string[],
config?: Config,
roots?: { name: string, uri: string }[],
}) => Promise<{ client: Client, stderr: () => string }>;
wsEndpoint: string;
cdpServer: CDPServer;
server: TestServer;
@@ -61,14 +67,11 @@ export const test = baseTest.extend<TestFixtures & TestOptions, WorkerFixtures>(
},
startClient: async ({ mcpHeadless, mcpBrowser, mcpMode }, use, testInfo) => {
const userDataDir = mcpMode !== 'docker' ? testInfo.outputPath('user-data-dir') : undefined;
const configDir = path.dirname(test.info().config.configFile!);
let client: Client | undefined;
await use(async options => {
const args: string[] = [];
if (userDataDir)
args.push('--user-data-dir', userDataDir);
if (process.env.CI && process.platform === 'linux')
args.push('--no-sandbox');
if (mcpHeadless)
@@ -83,8 +86,15 @@ export const test = baseTest.extend<TestFixtures & TestOptions, WorkerFixtures>(
args.push(`--config=${path.relative(configDir, configFile)}`);
}
client = new Client({ name: options?.clientName ?? 'test', version: '1.0.0' });
const { transport, stderr } = await createTransport(args, mcpMode);
client = new Client({ name: options?.clientName ?? 'test', version: '1.0.0' }, options?.roots ? { capabilities: { roots: {} } } : undefined);
if (options?.roots) {
client.setRequestHandler(ListRootsRequestSchema, async request => {
return {
roots: options.roots,
};
});
}
const { transport, stderr } = await createTransport(args, mcpMode, testInfo.outputPath('ms-playwright'));
let stderrBuffer = '';
stderr?.on('data', data => {
if (process.env.PWMCP_DEBUG)
@@ -160,7 +170,7 @@ export const test = baseTest.extend<TestFixtures & TestOptions, WorkerFixtures>(
},
});
async function createTransport(args: string[], mcpMode: TestOptions['mcpMode']): Promise<{
async function createTransport(args: string[], mcpMode: TestOptions['mcpMode'], profilesDir: string): Promise<{
transport: Transport,
stderr: Stream | null,
}> {
@@ -188,6 +198,7 @@ async function createTransport(args: string[], mcpMode: TestOptions['mcpMode']):
DEBUG: 'pw:mcp:test',
DEBUG_COLORS: '0',
DEBUG_HIDE_DATE: '1',
PWMCP_PROFILES_DIR_FOR_TEST: profilesDir,
},
});
return {
@@ -199,41 +210,14 @@ async function createTransport(args: string[], mcpMode: TestOptions['mcpMode']):
type Response = Awaited<ReturnType<Client['callTool']>>;
export const expect = baseExpect.extend({
toHaveTextContent(response: Response, content: string | RegExp) {
toHaveResponse(response: Response, object: any) {
const parsed = parseResponse(response);
const isNot = this.isNot;
try {
const text = (response.content as any)[0].text;
if (typeof content === 'string') {
if (isNot)
baseExpect(text.trim()).not.toBe(content.trim());
else
baseExpect(text.trim()).toBe(content.trim());
} else {
if (isNot)
baseExpect(text).not.toMatch(content);
else
baseExpect(text).toMatch(content);
}
} catch (e) {
return {
pass: isNot,
message: () => e.message,
};
}
return {
pass: !isNot,
message: () => ``,
};
},
toContainTextContent(response: Response, content: string) {
const isNot = this.isNot;
try {
const texts = (response.content as any).map(c => c.text).join('\n');
if (isNot)
expect(texts).not.toContain(content);
expect(parsed).not.toEqual(expect.objectContaining(object));
else
expect(texts).toContain(content);
expect(parsed).toEqual(expect.objectContaining(object));
} catch (e) {
return {
pass: isNot,
@@ -250,3 +234,48 @@ export const expect = baseExpect.extend({
export function formatOutput(output: string): string[] {
return output.split('\n').map(line => line.replace(/^pw:mcp:test /, '').replace(/user data dir.*/, 'user data dir').trim()).filter(Boolean);
}
function parseResponse(response: any) {
const text = response.content[0].text;
const sections = parseSections(text);
const result = sections.get('Result');
const code = sections.get('Ran Playwright code');
const tabs = sections.get('Open tabs');
const pageState = sections.get('Page state');
const consoleMessages = sections.get('New console messages');
const modalState = sections.get('Modal state');
const downloads = sections.get('Downloads');
const codeNoFrame = code?.replace(/^```js\n/, '').replace(/\n```$/, '');
const isError = response.isError;
const attachments = response.content.slice(1);
return {
result,
code: codeNoFrame,
tabs,
pageState,
consoleMessages,
modalState,
downloads,
isError,
attachments,
};
}
function parseSections(text: string): Map<string, string> {
const sections = new Map<string, string>();
const sectionHeaders = text.split(/^### /m).slice(1); // Remove empty first element
for (const section of sectionHeaders) {
const firstNewlineIndex = section.indexOf('\n');
if (firstNewlineIndex === -1)
continue;
const sectionName = section.substring(0, firstNewlineIndex);
const sectionContent = section.substring(firstNewlineIndex + 1).trim();
sections.set(sectionName, sectionContent);
}
return sections;
}

View File

@@ -21,6 +21,7 @@ for (const mcpHeadless of [false, true]) {
test.use({ mcpHeadless });
test.skip(process.platform === 'linux', 'Auto-detection wont let this test run on linux');
test.skip(({ mcpMode, mcpHeadless }) => mcpMode === 'docker' && !mcpHeadless, 'Headed mode is not supported in docker');
test('browser', async ({ client, server, mcpBrowser }) => {
test.skip(!['chrome', 'msedge', 'chromium'].includes(mcpBrowser ?? ''), 'Only chrome is supported for this test');
server.route('/', (req, res) => {
@@ -40,11 +41,9 @@ for (const mcpHeadless of [false, true]) {
},
});
expect(response).toContainTextContent(`Mozilla/5.0`);
if (mcpHeadless)
expect(response).toContainTextContent(`HeadlessChrome`);
else
expect(response).not.toContainTextContent(`HeadlessChrome`);
expect(response).toHaveResponse({
pageState: (mcpHeadless ? expect : expect.not).stringContaining(`HeadlessChrome`),
});
});
});
}

View File

@@ -22,9 +22,8 @@ test('stitched aria frames', async ({ client }) => {
arguments: {
url: `data:text/html,<h1>Hello</h1><iframe src="data:text/html,<button>World</button><main><iframe src='data:text/html,<p>Nested</p>'></iframe></main>"></iframe><iframe src="data:text/html,<h1>Should be invisible</h1>" style="display: none;"></iframe>`,
},
})).toContainTextContent(`
\`\`\`yaml
- generic [active] [ref=e1]:
})).toHaveResponse({
pageState: expect.stringContaining(`- generic [active] [ref=e1]:
- heading "Hello" [level=1] [ref=e2]
- iframe [ref=e3]:
- generic [active] [ref=f1e1]:
@@ -32,7 +31,8 @@ test('stitched aria frames', async ({ client }) => {
- main [ref=f1e3]:
- iframe [ref=f1e4]:
- paragraph [ref=f2e2]: Nested
\`\`\``);
\`\`\``),
});
expect(await client.callTool({
name: 'browser_click',
@@ -40,5 +40,7 @@ test('stitched aria frames', async ({ client }) => {
element: 'World',
ref: 'f1e2',
},
})).toContainTextContent(`// Click World`);
})).toHaveResponse({
code: `await page.locator('iframe').first().contentFrame().getByRole('button', { name: 'World' }).click();`,
});
});

View File

@@ -20,5 +20,7 @@ test('browser_install', async ({ client, mcpBrowser }) => {
test.skip(mcpBrowser !== 'chromium', 'Test only chromium');
expect(await client.callTool({
name: 'browser_install',
})).toContainTextContent(`### No open tabs`);
})).toHaveResponse({
tabs: expect.stringContaining(`No open tabs`),
});
});

View File

@@ -27,18 +27,17 @@ test('test reopen browser', async ({ startClient, server, mcpMode }) => {
expect(await client.callTool({
name: 'browser_close',
})).toContainTextContent(`### Ran Playwright code
\`\`\`js
await page.close()
\`\`\`
### No open tabs
Use the "browser_navigate" tool to navigate to a page first.`);
})).toHaveResponse({
code: `await page.close()`,
tabs: `No open tabs. Use the "browser_navigate" tool to navigate to a page first.`,
});
expect(await client.callTool({
name: 'browser_navigate',
arguments: { url: server.HELLO_WORLD },
})).toContainTextContent(`- generic [active] [ref=e1]: Hello, world!`);
})).toHaveResponse({
pageState: expect.stringContaining(`- generic [active] [ref=e1]: Hello, world!`),
});
await client.close();
@@ -68,7 +67,10 @@ test('executable path', async ({ startClient, server }) => {
name: 'browser_navigate',
arguments: { url: server.HELLO_WORLD },
});
expect(response).toContainTextContent(`executable doesn't exist`);
expect(response).toHaveResponse({
result: expect.stringContaining(`executable doesn't exist`),
isError: true,
});
});
test('persistent context', async ({ startClient, server }) => {
@@ -82,11 +84,12 @@ test('persistent context', async ({ startClient, server }) => {
`, 'text/html');
const { client } = await startClient();
const response = await client.callTool({
expect(await client.callTool({
name: 'browser_navigate',
arguments: { url: server.PREFIX },
})).toHaveResponse({
pageState: expect.stringContaining(`Storage: NO`),
});
expect(response).toContainTextContent(`Storage: NO`);
await new Promise(resolve => setTimeout(resolve, 3000));
@@ -95,12 +98,12 @@ test('persistent context', async ({ startClient, server }) => {
});
const { client: client2 } = await startClient();
const response2 = await client2.callTool({
expect(await client2.callTool({
name: 'browser_navigate',
arguments: { url: server.PREFIX },
})).toHaveResponse({
pageState: expect.stringContaining(`Storage: YES`),
});
expect(response2).toContainTextContent(`Storage: YES`);
});
test('isolated context', async ({ startClient, server }) => {
@@ -114,22 +117,24 @@ test('isolated context', async ({ startClient, server }) => {
`, 'text/html');
const { client: client1 } = await startClient({ args: [`--isolated`] });
const response = await client1.callTool({
expect(await client1.callTool({
name: 'browser_navigate',
arguments: { url: server.PREFIX },
})).toHaveResponse({
pageState: expect.stringContaining(`Storage: NO`),
});
expect(response).toContainTextContent(`Storage: NO`);
await client1.callTool({
name: 'browser_close',
});
const { client: client2 } = await startClient({ args: [`--isolated`] });
const response2 = await client2.callTool({
expect(await client2.callTool({
name: 'browser_navigate',
arguments: { url: server.PREFIX },
})).toHaveResponse({
pageState: expect.stringContaining(`Storage: NO`),
});
expect(response2).toContainTextContent(`Storage: NO`);
});
test('isolated context with storage state', async ({ startClient, server }, testInfo) => {
@@ -155,9 +160,10 @@ test('isolated context with storage state', async ({ startClient, server }, test
`--isolated`,
`--storage-state=${storageStatePath}`,
] });
const response = await client.callTool({
expect(await client.callTool({
name: 'browser_navigate',
arguments: { url: server.PREFIX },
})).toHaveResponse({
pageState: expect.stringContaining(`Storage: session-value`),
});
expect(response).toContainTextContent(`Storage: session-value`);
});

View File

@@ -40,7 +40,8 @@ test('browser_network_requests', async ({ client, server }) => {
await expect.poll(() => client.callTool({
name: 'browser_network_requests',
})).toHaveTextContent(`### Result
[GET] ${`${server.PREFIX}`} => [200] OK
[GET] ${`${server.PREFIX}json`} => [200] OK`);
})).toHaveResponse({
result: expect.stringContaining(`[GET] ${`${server.PREFIX}`} => [200] OK
[GET] ${`${server.PREFIX}json`} => [200] OK`),
});
});

View File

@@ -27,7 +27,10 @@ test('save as pdf unavailable', async ({ startClient, server }) => {
expect(await client.callTool({
name: 'browser_pdf_save',
})).toHaveTextContent(/Tool \"browser_pdf_save\" not found/);
})).toHaveResponse({
result: 'Error: Tool "browser_pdf_save" not found',
isError: true,
});
});
test('save as pdf', async ({ startClient, mcpBrowser, server }, testInfo) => {
@@ -40,12 +43,16 @@ test('save as pdf', async ({ startClient, mcpBrowser, server }, testInfo) => {
expect(await client.callTool({
name: 'browser_navigate',
arguments: { url: server.HELLO_WORLD },
})).toContainTextContent(`- generic [active] [ref=e1]: Hello, world!`);
const response = await client.callTool({
name: 'browser_pdf_save',
})).toHaveResponse({
pageState: expect.stringContaining(`- generic [active] [ref=e1]: Hello, world!`),
});
expect(await client.callTool({
name: 'browser_pdf_save',
})).toHaveResponse({
code: expect.stringContaining(`await page.pdf(`),
result: expect.stringMatching(/Saved page as.*page-[^:]+.pdf/),
});
expect(response).toHaveTextContent(/Save page as.*page-[^:]+.pdf/);
});
test('save as pdf (filename: output.pdf)', async ({ startClient, mcpBrowser, server }, testInfo) => {
@@ -58,14 +65,19 @@ test('save as pdf (filename: output.pdf)', async ({ startClient, mcpBrowser, ser
expect(await client.callTool({
name: 'browser_navigate',
arguments: { url: server.HELLO_WORLD },
})).toContainTextContent(`- generic [active] [ref=e1]: Hello, world!`);
})).toHaveResponse({
pageState: expect.stringContaining(`- generic [active] [ref=e1]: Hello, world!`),
});
expect(await client.callTool({
name: 'browser_pdf_save',
arguments: {
filename: 'output.pdf',
},
})).toContainTextContent(`output.pdf`);
})).toHaveResponse({
result: expect.stringContaining(`output.pdf`),
code: expect.stringContaining(`await page.pdf(`),
});
const files = [...fs.readdirSync(outputDir)];

170
tests/permissions.spec.ts Normal file
View File

@@ -0,0 +1,170 @@
/**
* 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('clipboard permissions support via CLI argument', async ({ startClient, server }) => {
server.setContent('/', `
<html>
<body>
<h1>Test Page</h1>
</body>
</html>
`, 'text/html');
const { client } = await startClient({ args: ['--permissions', 'clipboard-read,clipboard-write'] });
// Navigate to server page
const navigateResponse = await client.callTool({
name: 'browser_navigate',
arguments: { url: server.PREFIX },
});
expect(navigateResponse.isError).toBeFalsy();
// Verify permissions are granted
const permissionsResponse = await client.callTool({
name: 'browser_evaluate',
arguments: {
function: '() => navigator.permissions.query({ name: "clipboard-write" }).then(result => result.state)'
}
});
expect(permissionsResponse.isError).toBeFalsy();
expect(permissionsResponse).toHaveResponse({
result: '"granted"'
});
// Test clipboard write operation without user permission prompt
const writeResponse = await client.callTool({
name: 'browser_evaluate',
arguments: {
function: '() => navigator.clipboard.writeText("test clipboard content")'
}
});
expect(writeResponse.isError).toBeFalsy();
// Test clipboard read operation without user permission prompt
const readResponse = await client.callTool({
name: 'browser_evaluate',
arguments: {
function: '() => navigator.clipboard.readText()'
}
});
expect(readResponse.isError).toBeFalsy();
expect(readResponse).toHaveResponse({
result: '"test clipboard content"'
});
});
test('clipboard permissions support via config file contextOptions', async ({ startClient, server }) => {
server.setContent('/', `
<html>
<body>
<h1>Config Test Page</h1>
</body>
</html>
`, 'text/html');
const config = {
browser: {
contextOptions: {
permissions: ['clipboard-read', 'clipboard-write']
}
}
};
const { client } = await startClient({ config });
// Navigate to server page
const navigateResponse = await client.callTool({
name: 'browser_navigate',
arguments: { url: server.PREFIX },
});
expect(navigateResponse.isError).toBeFalsy();
// Verify permissions are granted via config
const permissionsResponse = await client.callTool({
name: 'browser_evaluate',
arguments: {
function: '() => navigator.permissions.query({ name: "clipboard-write" }).then(result => result.state)'
}
});
expect(permissionsResponse.isError).toBeFalsy();
expect(permissionsResponse).toHaveResponse({
result: '"granted"'
});
// Test clipboard operations work with config file
const writeResponse = await client.callTool({
name: 'browser_evaluate',
arguments: {
function: '() => navigator.clipboard.writeText("config test content")'
}
});
expect(writeResponse.isError).toBeFalsy();
const readResponse = await client.callTool({
name: 'browser_evaluate',
arguments: {
function: '() => navigator.clipboard.readText()'
}
});
expect(readResponse.isError).toBeFalsy();
expect(readResponse).toHaveResponse({
result: '"config test content"'
});
});
test('multiple permissions can be granted', async ({ startClient, server }) => {
server.setContent('/', `
<html>
<body>
<h1>Multiple Permissions Test</h1>
</body>
</html>
`, 'text/html');
const { client } = await startClient({ args: ['--permissions', 'clipboard-read,clipboard-write,geolocation'] });
// Navigate to server page
const navigateResponse = await client.callTool({
name: 'browser_navigate',
arguments: { url: server.PREFIX },
});
expect(navigateResponse.isError).toBeFalsy();
// Test that multiple permissions can be granted
const clipboardPermissionResponse = await client.callTool({
name: 'browser_evaluate',
arguments: {
function: '() => navigator.permissions.query({ name: "clipboard-write" }).then(result => result.state)'
}
});
expect(clipboardPermissionResponse.isError).toBeFalsy();
expect(clipboardPermissionResponse).toHaveResponse({
result: '"granted"'
});
const geolocationPermissionResponse = await client.callTool({
name: 'browser_evaluate',
arguments: {
function: '() => navigator.permissions.query({ name: "geolocation" }).then(result => result.state)'
}
});
expect(geolocationPermissionResponse.isError).toBeFalsy();
expect(geolocationPermissionResponse).toHaveResponse({
result: '"granted"'
});
});

68
tests/roots.spec.ts Normal file
View File

@@ -0,0 +1,68 @@
/**
* Copyright (c) Microsoft Corporation.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import fs from 'fs';
import path from 'path';
import { pathToFileURL } from 'url';
import { test, expect } from './fixtures.js';
import { createHash } from '../src/utils.js';
const p = process.platform === 'win32' ? 'c:\\non\\existent\\folder' : '/non/existent/folder';
test('should use separate user data by root path', async ({ startClient, server }, testInfo) => {
const { client } = await startClient({
roots: [
{
name: 'test',
uri: 'file://' + p.replace(/\\/g, '/'),
}
],
});
await client.callTool({
name: 'browser_navigate',
arguments: { url: server.HELLO_WORLD },
});
const hash = createHash(p);
const [file] = await fs.promises.readdir(testInfo.outputPath('ms-playwright'));
expect(file).toContain(hash);
});
test('check that trace is saved in workspace', async ({ startClient, server, mcpMode }, testInfo) => {
const rootPath = testInfo.outputPath('workspace');
const { client } = await startClient({
args: ['--save-trace'],
roots: [
{
name: 'workspace',
uri: pathToFileURL(rootPath).toString(),
},
],
});
expect(await client.callTool({
name: 'browser_navigate',
arguments: { url: server.HELLO_WORLD },
})).toHaveResponse({
code: expect.stringContaining(`page.goto('http://localhost`),
});
const [file] = await fs.promises.readdir(path.join(rootPath, '.playwright-mcp'));
expect(file).toContain('traces');
});

View File

@@ -25,22 +25,19 @@ test('browser_take_screenshot (viewport)', async ({ startClient, server }, testI
expect(await client.callTool({
name: 'browser_navigate',
arguments: { url: server.HELLO_WORLD },
})).toContainTextContent(`Navigate to http://localhost`);
})).toHaveResponse({
code: expect.stringContaining(`page.goto('http://localhost`),
});
expect(await client.callTool({
name: 'browser_take_screenshot',
})).toEqual({
content: [
{
text: expect.stringContaining(`Screenshot viewport and save it as`),
type: 'text',
},
{
data: expect.any(String),
mimeType: 'image/jpeg',
type: 'image',
},
],
})).toHaveResponse({
code: expect.stringContaining(`await page.screenshot`),
attachments: [{
data: expect.any(String),
mimeType: 'image/png',
type: 'image',
}],
});
});
@@ -51,7 +48,9 @@ test('browser_take_screenshot (element)', async ({ startClient, server }, testIn
expect(await client.callTool({
name: 'browser_navigate',
arguments: { url: server.HELLO_WORLD },
})).toContainTextContent(`[ref=e1]`);
})).toHaveResponse({
pageState: expect.stringContaining(`[ref=e1]`),
});
expect(await client.callTool({
name: 'browser_take_screenshot',
@@ -67,7 +66,7 @@ test('browser_take_screenshot (element)', async ({ startClient, server }, testIn
},
{
data: expect.any(String),
mimeType: 'image/jpeg',
mimeType: 'image/png',
type: 'image',
},
],
@@ -82,61 +81,101 @@ test('--output-dir should work', async ({ startClient, server }, testInfo) => {
expect(await client.callTool({
name: 'browser_navigate',
arguments: { url: server.HELLO_WORLD },
})).toContainTextContent(`Navigate to http://localhost`);
})).toHaveResponse({
code: expect.stringContaining(`page.goto('http://localhost`),
});
await client.callTool({
name: 'browser_take_screenshot',
});
expect(fs.existsSync(outputDir)).toBeTruthy();
const files = [...fs.readdirSync(outputDir)].filter(f => f.endsWith('.jpeg'));
const files = [...fs.readdirSync(outputDir)].filter(f => f.endsWith('.png'));
expect(files).toHaveLength(1);
expect(files[0]).toMatch(/^page-\d{4}-\d{2}-\d{2}T\d{2}-\d{2}-\d{2}-\d{3}Z\.jpeg$/);
expect(files[0]).toMatch(/^page-\d{4}-\d{2}-\d{2}T\d{2}-\d{2}-\d{2}-\d{3}Z\.png$/);
});
for (const raw of [undefined, true]) {
test(`browser_take_screenshot (raw: ${raw})`, async ({ startClient, server }, testInfo) => {
for (const type of ['png', 'jpeg']) {
test(`browser_take_screenshot (type: ${type})`, async ({ startClient, server }, testInfo) => {
const outputDir = testInfo.outputPath('output');
const ext = raw ? 'png' : 'jpeg';
const { client } = await startClient({
config: { outputDir },
});
expect(await client.callTool({
name: 'browser_navigate',
arguments: { url: server.PREFIX },
})).toContainTextContent(`Navigate to http://localhost`);
})).toHaveResponse({
code: expect.stringContaining(`page.goto('http://localhost`),
});
expect(await client.callTool({
name: 'browser_take_screenshot',
arguments: { raw },
arguments: { type },
})).toEqual({
content: [
{
text: expect.stringMatching(
new RegExp(`page-\\d{4}-\\d{2}-\\d{2}T\\d{2}-\\d{2}-\\d{2}\\-\\d{3}Z\\.${ext}`)
new RegExp(`page-\\d{4}-\\d{2}-\\d{2}T\\d{2}-\\d{2}-\\d{2}\\-\\d{3}Z\\.${type}`)
),
type: 'text',
},
{
data: expect.any(String),
mimeType: `image/${ext}`,
mimeType: `image/${type}`,
type: 'image',
},
],
});
const files = [...fs.readdirSync(outputDir)].filter(f => f.endsWith(`.${ext}`));
const files = [...fs.readdirSync(outputDir)].filter(f => f.endsWith(`.${type}`));
expect(fs.existsSync(outputDir)).toBeTruthy();
expect(files).toHaveLength(1);
expect(files[0]).toMatch(
new RegExp(`^page-\\d{4}-\\d{2}-\\d{2}T\\d{2}-\\d{2}-\\d{2}-\\d{3}Z\\.${ext}$`)
new RegExp(`^page-\\d{4}-\\d{2}-\\d{2}T\\d{2}-\\d{2}-\\d{2}-\\d{3}Z\\.${type}$`)
);
});
}
test('browser_take_screenshot (filename: "output.jpeg")', async ({ startClient, server }, testInfo) => {
test('browser_take_screenshot (default type should be png)', async ({ startClient, server }, testInfo) => {
const outputDir = testInfo.outputPath('output');
const { client } = await startClient({
config: { outputDir },
});
expect(await client.callTool({
name: 'browser_navigate',
arguments: { url: server.PREFIX },
})).toHaveResponse({
code: `await page.goto('${server.PREFIX}');`,
});
expect(await client.callTool({
name: 'browser_take_screenshot',
})).toEqual({
content: [
{
text: expect.stringMatching(
new RegExp(`page-\\d{4}-\\d{2}-\\d{2}T\\d{2}-\\d{2}-\\d{2}\\-\\d{3}Z\\.png`)
),
type: 'text',
},
{
data: expect.any(String),
mimeType: 'image/png',
type: 'image',
},
],
});
const files = [...fs.readdirSync(outputDir)].filter(f => f.endsWith('.png'));
expect(fs.existsSync(outputDir)).toBeTruthy();
expect(files).toHaveLength(1);
expect(files[0]).toMatch(/^page-\d{4}-\d{2}-\d{2}T\d{2}-\d{2}-\d{2}-\d{3}Z\.png$/);
});
test('browser_take_screenshot (filename: "output.png")', async ({ startClient, server }, testInfo) => {
const outputDir = testInfo.outputPath('output');
const { client } = await startClient({
config: { outputDir },
@@ -144,32 +183,34 @@ test('browser_take_screenshot (filename: "output.jpeg")', async ({ startClient,
expect(await client.callTool({
name: 'browser_navigate',
arguments: { url: server.HELLO_WORLD },
})).toContainTextContent(`Navigate to http://localhost`);
})).toHaveResponse({
code: expect.stringContaining(`page.goto('http://localhost`),
});
expect(await client.callTool({
name: 'browser_take_screenshot',
arguments: {
filename: 'output.jpeg',
filename: 'output.png',
},
})).toEqual({
content: [
{
text: expect.stringContaining(`output.jpeg`),
text: expect.stringContaining(`output.png`),
type: 'text',
},
{
data: expect.any(String),
mimeType: 'image/jpeg',
mimeType: 'image/png',
type: 'image',
},
],
});
const files = [...fs.readdirSync(outputDir)].filter(f => f.endsWith('.jpeg'));
const files = [...fs.readdirSync(outputDir)].filter(f => f.endsWith('.png'));
expect(fs.existsSync(outputDir)).toBeTruthy();
expect(files).toHaveLength(1);
expect(files[0]).toMatch(/^output\.jpeg$/);
expect(files[0]).toMatch(/^output\.png$/);
});
test('browser_take_screenshot (imageResponses=omit)', async ({ startClient, server }, testInfo) => {
@@ -184,7 +225,9 @@ test('browser_take_screenshot (imageResponses=omit)', async ({ startClient, serv
expect(await client.callTool({
name: 'browser_navigate',
arguments: { url: server.HELLO_WORLD },
})).toContainTextContent(`Navigate to http://localhost`);
})).toHaveResponse({
code: expect.stringContaining(`page.goto('http://localhost`),
});
await client.callTool({
name: 'browser_take_screenshot',
@@ -195,7 +238,7 @@ test('browser_take_screenshot (imageResponses=omit)', async ({ startClient, serv
})).toEqual({
content: [
{
text: expect.stringContaining(`Screenshot viewport and save it as`),
text: expect.stringContaining(`await page.screenshot`),
type: 'text',
},
],
@@ -209,7 +252,9 @@ test('browser_take_screenshot (fullPage: true)', async ({ startClient, server },
expect(await client.callTool({
name: 'browser_navigate',
arguments: { url: server.HELLO_WORLD },
})).toContainTextContent(`Navigate to http://localhost`);
})).toHaveResponse({
code: expect.stringContaining(`page.goto('http://localhost`),
});
expect(await client.callTool({
name: 'browser_take_screenshot',
@@ -217,12 +262,12 @@ test('browser_take_screenshot (fullPage: true)', async ({ startClient, server },
})).toEqual({
content: [
{
text: expect.stringContaining(`Screenshot full page and save it as`),
text: expect.stringContaining('fullPage: true'),
type: 'text',
},
{
data: expect.any(String),
mimeType: 'image/jpeg',
mimeType: 'image/png',
type: 'image',
},
],
@@ -236,7 +281,9 @@ test('browser_take_screenshot (fullPage with element should error)', async ({ st
expect(await client.callTool({
name: 'browser_navigate',
arguments: { url: server.HELLO_WORLD },
})).toContainTextContent(`[ref=e1]`);
})).toHaveResponse({
pageState: expect.stringContaining(`[ref=e1]`),
});
const result = await client.callTool({
name: 'browser_take_screenshot',
@@ -259,7 +306,9 @@ 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_tab_list',
})).toContainTextContent('about:blank');
})).toHaveResponse({
tabs: `- 0: (current) [] (about:blank)`,
});
// This should work without requiring a snapshot since it's a viewport screenshot
expect(await client.callTool({
@@ -267,12 +316,12 @@ test('browser_take_screenshot (viewport without snapshot)', async ({ startClient
})).toEqual({
content: [
{
text: expect.stringContaining(`Screenshot viewport and save it as`),
text: expect.stringContaining(`page.screenshot`),
type: 'text',
},
{
data: expect.any(String),
mimeType: 'image/jpeg',
mimeType: 'image/png',
type: 'image',
},
],

275
tests/session-log.spec.ts Normal file
View File

@@ -0,0 +1,275 @@
/**
* Copyright (c) Microsoft Corporation.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import fs from 'fs';
import path from 'path';
import { test, expect } from './fixtures.js';
test('session log should record tool calls', async ({ startClient, server }, testInfo) => {
const { client, stderr } = await startClient({
args: [
'--save-session',
'--output-dir', testInfo.outputPath('output'),
],
});
server.setContent('/', `<title>Title</title><button>Submit</button>`, 'text/html');
await client.callTool({
name: 'browser_navigate',
arguments: { url: server.PREFIX },
});
expect(await client.callTool({
name: 'browser_click',
arguments: {
element: 'Submit button',
ref: 'e2',
},
})).toHaveResponse({
code: `await page.getByRole('button', { name: 'Submit' }).click();`,
pageState: expect.stringContaining(`- button "Submit"`),
});
const output = stderr().split('\n').filter(line => line.startsWith('Session: '))[0];
const sessionFolder = output.substring('Session: '.length);
await expect.poll(() => readSessionLog(sessionFolder)).toBe(`
### Tool call: browser_navigate
- Args
\`\`\`json
{
"url": "http://localhost:${server.PORT}/"
}
\`\`\`
- Code
\`\`\`js
await page.goto('http://localhost:${server.PORT}/');
\`\`\`
- Snapshot: 001.snapshot.yml
### Tool call: browser_click
- Args
\`\`\`json
{
"element": "Submit button",
"ref": "e2"
}
\`\`\`
- Code
\`\`\`js
await page.getByRole('button', { name: 'Submit' }).click();
\`\`\`
- Snapshot: 002.snapshot.yml
`);
});
test('session log should record user action', async ({ cdpServer, startClient }, testInfo) => {
const browserContext = await cdpServer.start();
const { client, stderr } = await startClient({
args: [
'--save-session',
'--output-dir', testInfo.outputPath('output'),
`--cdp-endpoint=${cdpServer.endpoint}`,
],
});
// Force browser context creation.
await client.callTool({
name: 'browser_snapshot',
});
const [page] = browserContext.pages();
await page.setContent(`
<button>Button 1</button>
<button>Button 2</button>
`);
await page.getByRole('button', { name: 'Button 1' }).click();
const output = stderr().split('\n').filter(line => line.startsWith('Session: '))[0];
const sessionFolder = output.substring('Session: '.length);
await expect.poll(() => readSessionLog(sessionFolder)).toBe(`
### Tool call: browser_snapshot
- Args
\`\`\`json
{}
\`\`\`
- Snapshot: 001.snapshot.yml
### User action: click
- Args
\`\`\`json
{
"name": "click",
"ref": "e2",
"button": "left",
"modifiers": 0,
"clickCount": 1
}
\`\`\`
- Code
\`\`\`js
await page.getByRole('button', { name: 'Button 1' }).click();
\`\`\`
- Snapshot: 002.snapshot.yml
`);
});
test('session log should update user action', async ({ cdpServer, startClient }, testInfo) => {
const browserContext = await cdpServer.start();
const { client, stderr } = await startClient({
args: [
'--save-session',
'--output-dir', testInfo.outputPath('output'),
`--cdp-endpoint=${cdpServer.endpoint}`,
],
});
// Force browser context creation.
await client.callTool({
name: 'browser_snapshot',
});
const [page] = browserContext.pages();
await page.setContent(`
<button>Button 1</button>
<button>Button 2</button>
`);
await page.getByRole('button', { name: 'Button 1' }).dblclick();
const output = stderr().split('\n').filter(line => line.startsWith('Session: '))[0];
const sessionFolder = output.substring('Session: '.length);
await expect.poll(() => readSessionLog(sessionFolder)).toBe(`
### Tool call: browser_snapshot
- Args
\`\`\`json
{}
\`\`\`
- Snapshot: 001.snapshot.yml
### User action: click
- Args
\`\`\`json
{
"name": "click",
"ref": "e2",
"button": "left",
"modifiers": 0,
"clickCount": 2
}
\`\`\`
- Code
\`\`\`js
await page.getByRole('button', { name: 'Button 1' }).dblclick();
\`\`\`
- Snapshot: 002.snapshot.yml
`);
});
test('session log should record tool calls and user actions', async ({ cdpServer, startClient }, testInfo) => {
const browserContext = await cdpServer.start();
const { client, stderr } = await startClient({
args: [
'--save-session',
'--output-dir', testInfo.outputPath('output'),
`--cdp-endpoint=${cdpServer.endpoint}`,
],
});
const [page] = browserContext.pages();
await page.setContent(`
<button>Button 1</button>
<button>Button 2</button>
`);
await client.callTool({
name: 'browser_snapshot',
});
// Manual action.
await page.getByRole('button', { name: 'Button 1' }).click();
// This is to simulate a delay after the user action before the tool action.
await new Promise(resolve => setTimeout(resolve, 1000));
// Tool action.
await client.callTool({
name: 'browser_click',
arguments: {
element: 'Button 2',
ref: 'e3',
},
});
const output = stderr().split('\n').filter(line => line.startsWith('Session: '))[0];
const sessionFolder = output.substring('Session: '.length);
await expect.poll(() => readSessionLog(sessionFolder)).toBe(`
### Tool call: browser_snapshot
- Args
\`\`\`json
{}
\`\`\`
- Snapshot: 001.snapshot.yml
### User action: click
- Args
\`\`\`json
{
"name": "click",
"ref": "e2",
"button": "left",
"modifiers": 0,
"clickCount": 1
}
\`\`\`
- Code
\`\`\`js
await page.getByRole('button', { name: 'Button 1' }).click();
\`\`\`
- Snapshot: 002.snapshot.yml
### Tool call: browser_click
- Args
\`\`\`json
{
"element": "Button 2",
"ref": "e3"
}
\`\`\`
- Code
\`\`\`js
await page.getByRole('button', { name: 'Button 2' }).click();
\`\`\`
- Snapshot: 003.snapshot.yml
`);
});
async function readSessionLog(sessionFolder: string): Promise<string> {
return await fs.promises.readFile(path.join(sessionFolder, 'session.md'), 'utf8').catch(() => '');
}

View File

@@ -30,99 +30,87 @@ async function createTab(client: Client, title: string, body: string) {
test('list initial tabs', async ({ client }) => {
expect(await client.callTool({
name: 'browser_tab_list',
})).toHaveTextContent(`### Open tabs
- 0: (current) [] (about:blank)`);
})).toHaveResponse({
tabs: `- 0: (current) [] (about:blank)`,
});
});
test('list first tab', async ({ client }) => {
await createTab(client, 'Tab one', 'Body one');
expect(await client.callTool({
name: 'browser_tab_list',
})).toHaveTextContent(`### Open tabs
- 0: [] (about:blank)
- 1: (current) [Tab one] (data:text/html,<title>Tab one</title><body>Body one</body>)`);
})).toHaveResponse({
tabs: `- 0: [] (about:blank)
- 1: (current) [Tab one] (data:text/html,<title>Tab one</title><body>Body one</body>)`,
});
});
test('create new tab', async ({ client }) => {
const result = await createTab(client, 'Tab one', 'Body one');
expect(result).toContainTextContent(`### Open tabs
- 0: [] (about:blank)
- 1: (current) [Tab one] (data:text/html,<title>Tab one</title><body>Body one</body>)
`);
expect(result).toContainTextContent(`
### Page state
- Page URL: data:text/html,<title>Tab one</title><body>Body one</body>
expect(await createTab(client, 'Tab one', 'Body one')).toHaveResponse({
tabs: `- 0: [] (about:blank)
- 1: (current) [Tab one] (data:text/html,<title>Tab one</title><body>Body one</body>)`,
pageState: expect.stringContaining(`- Page URL: data:text/html,<title>Tab one</title><body>Body one</body>
- Page Title: Tab one
- Page Snapshot:
\`\`\`yaml
- generic [active] [ref=e1]: Body one
\`\`\``);
\`\`\``),
});
const result2 = await createTab(client, 'Tab two', 'Body two');
expect(result2).toContainTextContent(`### Open tabs
- 0: [] (about:blank)
expect(await createTab(client, 'Tab two', 'Body two')).toHaveResponse({
tabs: `- 0: [] (about:blank)
- 1: [Tab one] (data:text/html,<title>Tab one</title><body>Body one</body>)
- 2: (current) [Tab two] (data:text/html,<title>Tab two</title><body>Body two</body>)
`);
expect(result2).toContainTextContent(`
### Page state
- Page URL: data:text/html,<title>Tab two</title><body>Body two</body>
- 2: (current) [Tab two] (data:text/html,<title>Tab two</title><body>Body two</body>)`,
pageState: expect.stringContaining(`- Page URL: data:text/html,<title>Tab two</title><body>Body two</body>
- Page Title: Tab two
- Page Snapshot:
\`\`\`yaml
- generic [active] [ref=e1]: Body two
\`\`\``);
\`\`\``),
});
});
test('select tab', async ({ client }) => {
await createTab(client, 'Tab one', 'Body one');
await createTab(client, 'Tab two', 'Body two');
const result = await client.callTool({
expect(await client.callTool({
name: 'browser_tab_select',
arguments: {
index: 1,
},
});
expect(result).toContainTextContent(`### Open tabs
- 0: [] (about:blank)
})).toHaveResponse({
tabs: `- 0: [] (about:blank)
- 1: (current) [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>)`);
expect(result).toContainTextContent(`
### Page state
- Page URL: 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: data:text/html,<title>Tab one</title><body>Body one</body>
- Page Title: Tab one
- Page Snapshot:
\`\`\`yaml
- generic [active] [ref=e1]: Body one
\`\`\``);
\`\`\``),
});
});
test('close tab', async ({ client }) => {
await createTab(client, 'Tab one', 'Body one');
await createTab(client, 'Tab two', 'Body two');
const result = await client.callTool({
expect(await client.callTool({
name: 'browser_tab_close',
arguments: {
index: 2,
},
});
expect(result).toContainTextContent(`### Open tabs
- 0: [] (about:blank)
- 1: (current) [Tab one] (data:text/html,<title>Tab one</title><body>Body one</body>)`);
expect(result).toContainTextContent(`
### Page state
- Page URL: data:text/html,<title>Tab one</title><body>Body one</body>
})).toHaveResponse({
tabs: `- 0: [] (about:blank)
- 1: (current) [Tab one] (data:text/html,<title>Tab one</title><body>Body one</body>)`,
pageState: expect.stringContaining(`- Page URL: data:text/html,<title>Tab one</title><body>Body one</body>
- Page Title: Tab one
- Page Snapshot:
\`\`\`yaml
- generic [active] [ref=e1]: Body one
\`\`\``);
\`\`\``),
});
});
test('reuse first tab when navigating', async ({ startClient, cdpServer, server }) => {

View File

@@ -15,7 +15,6 @@
*/
import fs from 'fs';
import path from 'path';
import { test, expect } from './fixtures.js';
@@ -29,7 +28,10 @@ test('check that trace is saved', async ({ startClient, server, mcpMode }, testI
expect(await client.callTool({
name: 'browser_navigate',
arguments: { url: server.HELLO_WORLD },
})).toContainTextContent(`Navigate to http://localhost`);
})).toHaveResponse({
code: expect.stringContaining(`page.goto('http://localhost`),
});
expect(fs.existsSync(path.join(outputDir, 'traces', 'trace.trace'))).toBeTruthy();
const [file] = await fs.promises.readdir(outputDir);
expect(file).toContain('traces');
});

View File

@@ -41,13 +41,18 @@ test('browser_type', async ({ client, server }) => {
submit: true,
},
});
expect(response).toContainTextContent(`fill('Hi!');`);
expect(response).toContainTextContent(`- textbox`);
expect(response).toHaveResponse({
code: `await page.getByRole('textbox').fill('Hi!');
await page.getByRole('textbox').press('Enter');`,
pageState: expect.stringContaining(`- textbox`),
});
}
expect(await client.callTool({
name: 'browser_console_messages',
})).toHaveTextContent(/\[LOG\] Key pressed: Enter , Text: Hi!/);
})).toHaveResponse({
result: expect.stringContaining(`[LOG] Key pressed: Enter , Text: Hi!`),
});
});
test('browser_type (slowly)', async ({ client, server }) => {
@@ -72,15 +77,23 @@ test('browser_type (slowly)', async ({ client, server }) => {
},
});
expect(response).toContainTextContent(`pressSequentially('Hi!');`);
expect(response).toContainTextContent(`- textbox`);
expect(response).toHaveResponse({
code: `await page.getByRole('textbox').pressSequentially('Hi!');`,
pageState: expect.stringContaining(`- textbox`),
});
}
const response = await client.callTool({
name: 'browser_console_messages',
});
expect(response).toHaveTextContent(/\[LOG\] Key pressed: H Text: /);
expect(response).toHaveTextContent(/\[LOG\] Key pressed: i Text: H/);
expect(response).toHaveTextContent(/\[LOG\] Key pressed: ! Text: Hi/);
expect(response).toHaveResponse({
result: expect.stringContaining(`[LOG] Key pressed: H Text: `),
});
expect(response).toHaveResponse({
result: expect.stringContaining(`[LOG] Key pressed: i Text: H`),
});
expect(response).toHaveResponse({
result: expect.stringContaining(`[LOG] Key pressed: ! Text: Hi`),
});
});
test('browser_type (no submit)', async ({ client, server }) => {
@@ -95,7 +108,9 @@ test('browser_type (no submit)', async ({ client, server }) => {
url: server.PREFIX,
},
});
expect(response).toContainTextContent(`- textbox`);
expect(response).toHaveResponse({
pageState: expect.stringContaining(`- textbox`),
});
}
{
const response = await client.callTool({
@@ -106,14 +121,18 @@ test('browser_type (no submit)', async ({ client, server }) => {
text: 'Hi!',
},
});
expect(response).toContainTextContent(`fill('Hi!');`);
// Should yield no snapshot.
expect(response).not.toContainTextContent(`- textbox`);
expect(response).toHaveResponse({
code: expect.stringContaining(`fill('Hi!')`),
// Should yield no snapshot.
pageState: expect.not.stringContaining(`- textbox`),
});
}
{
const response = await client.callTool({
name: 'browser_console_messages',
});
expect(response).toHaveTextContent(/\[LOG\] New value: Hi!/);
expect(response).toHaveResponse({
result: expect.stringContaining(`[LOG] New value: Hi!`),
});
}
});

View File

@@ -47,7 +47,9 @@ test('browser_wait_for(text)', async ({ client, server }) => {
expect(await client.callTool({
name: 'browser_wait_for',
arguments: { text: 'Text to appear' },
})).toContainTextContent(`- generic [ref=e3]: Text to appear`);
})).toHaveResponse({
pageState: expect.stringContaining(`- generic [ref=e3]: Text to appear`),
});
});
test('browser_wait_for(textGone)', async ({ client, server }) => {
@@ -81,5 +83,7 @@ test('browser_wait_for(textGone)', async ({ client, server }) => {
expect(await client.callTool({
name: 'browser_wait_for',
arguments: { textGone: 'Text to disappear' },
})).toContainTextContent(`- generic [ref=e3]: Text to appear`);
})).toHaveResponse({
pageState: expect.stringContaining(`- generic [ref=e3]: Text to appear`),
});
});

View File

@@ -34,5 +34,7 @@ test('do not falsely advertise user agent as a test driver', async ({ client, se
arguments: {
url: server.PREFIX,
},
})).toContainTextContent('webdriver: false');
})).toHaveResponse({
pageState: expect.stringContaining(`webdriver: false`),
});
});

View File

@@ -1,4 +1,4 @@
{
"extends": "./tsconfig.json",
"include": ["**/*.ts", "**/*.js"],
"include": ["**/*.ts", "**/*.tsx", "**/*.js"],
}