chore: introduce capabilities argument (#135)

This commit is contained in:
Pavel Feldman 2025-04-04 17:14:30 -07:00 committed by GitHub
parent 707ebbf4d4
commit abd56f514b
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
24 changed files with 432 additions and 242 deletions

View File

@ -68,6 +68,7 @@ The Playwright MCP server supports the following command-line options:
- Chrome channels: `chrome-beta`, `chrome-canary`, `chrome-dev`
- Edge channels: `msedge-beta`, `msedge-canary`, `msedge-dev`
- Default: `chrome`
- `--caps <caps>`: Comma-separated list of capabilities to enable, possible values: tabs, pdf, history, wait, files, install. Default is all.
- `--cdp-endpoint <endpoint>`: CDP endpoint to connect to
- `--executable-path <path>`: Path to the browser executable
- `--headless`: Run browser in headless mode (headed by default)
@ -299,7 +300,7 @@ server.connect(transport);
### Files and Media
- **browser_choose_file**
- **browser_file_upload**
- Description: Choose one or multiple files to upload
- Parameters:
- `paths` (array): The absolute paths to the files to upload. Can be a single file or multiple files.

7
index.d.ts vendored
View File

@ -18,6 +18,8 @@
import type { LaunchOptions } from 'playwright';
import type { Server } from '@modelcontextprotocol/sdk/server/index.js';
type ToolCapability = 'core' | 'tabs' | 'pdf' | 'history' | 'wait' | 'files' | 'install';
type Options = {
/**
* Path to the user data directory.
@ -35,6 +37,11 @@ type Options = {
* @default false
*/
vision?: boolean;
/**
* Capabilities to enable.
*/
capabilities?: ToolCapability[];
};
export function createServer(options?: Options): Server;

View File

@ -282,7 +282,7 @@ class PageSnapshot {
results.push('');
}
if (options?.hasFileChooser) {
results.push('- There is a file chooser visible that requires browser_choose_file to be called');
results.push('- There is a file chooser visible that requires browser_file_upload to be called');
results.push('');
}
results.push(this._text);

View File

@ -16,7 +16,7 @@
import { createServerWithTools } from './server';
import common from './tools/common';
import fileChooser from './tools/fileChooser';
import files from './tools/files';
import install from './tools/install';
import keyboard from './tools/keyboard';
import navigate from './tools/navigate';
@ -24,16 +24,16 @@ import pdf from './tools/pdf';
import snapshot from './tools/snapshot';
import tabs from './tools/tabs';
import screen from './tools/screen';
import { console } from './resources/console';
import { console as consoleResource } from './resources/console';
import type { Tool } from './tools/tool';
import type { Tool, ToolCapability } from './tools/tool';
import type { Resource } from './resources/resource';
import type { Server } from '@modelcontextprotocol/sdk/server/index.js';
import type { LaunchOptions } from 'playwright';
const snapshotTools: Tool[] = [
...common,
...fileChooser(true),
...files(true),
...install,
...keyboard(true),
...navigate(true),
@ -44,7 +44,7 @@ const snapshotTools: Tool[] = [
const screenshotTools: Tool[] = [
...common,
...fileChooser(false),
...files(false),
...install,
...keyboard(false),
...navigate(false),
@ -54,7 +54,7 @@ const screenshotTools: Tool[] = [
];
const resources: Resource[] = [
console,
consoleResource,
];
type Options = {
@ -63,12 +63,14 @@ type Options = {
launchOptions?: LaunchOptions;
cdpEndpoint?: string;
vision?: boolean;
capabilities?: ToolCapability[];
};
const packageJSON = require('../package.json');
export function createServer(options?: Options): Server {
const tools = options?.vision ? screenshotTools : snapshotTools;
const allTools = options?.vision ? screenshotTools : snapshotTools;
const tools = allTools.filter(tool => !options?.capabilities || tool.capability === 'core' || options.capabilities.includes(tool.capability));
return createServerWithTools({
name: 'Playwright',
version: packageJSON.version,

View File

@ -29,6 +29,7 @@ import { ServerList } from './server';
import type { LaunchOptions } from 'playwright';
import assert from 'assert';
import { ToolCapability } from './tools/tool';
const packageJSON = require('../package.json');
@ -36,6 +37,7 @@ program
.version('Version ' + packageJSON.version)
.name(packageJSON.name)
.option('--browser <browser>', 'Browser or chrome channel to use, possible values: chrome, firefox, webkit, msedge.')
.option('--caps <caps>', 'Comma-separated list of capabilities to enable, possible values: tabs, pdf, history, wait, files, install. Default is all.')
.option('--cdp-endpoint <endpoint>', 'CDP endpoint to connect to.')
.option('--executable-path <path>', 'Path to the browser executable.')
.option('--headless', 'Run browser in headless mode, headed by default')
@ -85,6 +87,7 @@ program
launchOptions,
vision: !!options.vision,
cdpEndpoint: options.cdpEndpoint,
capabilities: options.caps?.split(',').map((c: string) => c.trim() as ToolCapability),
}));
setupExitWatchdog(serverList);

View File

@ -24,6 +24,7 @@ const waitSchema = z.object({
});
const wait: Tool = {
capability: 'wait',
schema: {
name: 'browser_wait',
description: 'Wait for a specified time in seconds',
@ -44,6 +45,7 @@ const wait: Tool = {
const closeSchema = z.object({});
const close: Tool = {
capability: 'core',
schema: {
name: 'browser_close',
description: 'Close the page',

View File

@ -19,18 +19,19 @@ import { zodToJsonSchema } from 'zod-to-json-schema';
import type { ToolFactory } from './tool';
const chooseFileSchema = z.object({
const uploadFileSchema = z.object({
paths: z.array(z.string()).describe('The absolute paths to the files to upload. Can be a single file or multiple files.'),
});
const chooseFile: ToolFactory = captureSnapshot => ({
const uploadFile: ToolFactory = captureSnapshot => ({
capability: 'files',
schema: {
name: 'browser_choose_file',
description: 'Choose one or multiple files to upload',
inputSchema: zodToJsonSchema(chooseFileSchema),
name: 'browser_file_upload',
description: 'Upload one or multiple files',
inputSchema: zodToJsonSchema(uploadFileSchema),
},
handle: async (context, params) => {
const validatedParams = chooseFileSchema.parse(params);
const validatedParams = uploadFileSchema.parse(params);
const tab = context.currentTab();
return await tab.runAndWait(async () => {
await tab.submitFileChooser(validatedParams.paths);
@ -43,5 +44,5 @@ const chooseFile: ToolFactory = captureSnapshot => ({
});
export default (captureSnapshot: boolean) => [
chooseFile(captureSnapshot),
uploadFile(captureSnapshot),
];

View File

@ -23,6 +23,7 @@ import { zodToJsonSchema } from 'zod-to-json-schema';
import type { Tool } from './tool';
const install: Tool = {
capability: 'install',
schema: {
name: 'browser_install',
description: 'Install the browser specified in the config. Call this if you get an error about the browser not being installed.',

View File

@ -24,6 +24,7 @@ const pressKeySchema = z.object({
});
const pressKey: ToolFactory = captureSnapshot => ({
capability: 'core',
schema: {
name: 'browser_press_key',
description: 'Press a key on the keyboard',

View File

@ -24,6 +24,7 @@ const navigateSchema = z.object({
});
const navigate: ToolFactory = captureSnapshot => ({
capability: 'core',
schema: {
name: 'browser_navigate',
description: 'Navigate to a URL',
@ -44,6 +45,7 @@ const navigate: ToolFactory = captureSnapshot => ({
const goBackSchema = z.object({});
const goBack: ToolFactory = snapshot => ({
capability: 'history',
schema: {
name: 'browser_navigate_back',
description: 'Go back to the previous page',
@ -62,6 +64,7 @@ const goBack: ToolFactory = snapshot => ({
const goForwardSchema = z.object({});
const goForward: ToolFactory = snapshot => ({
capability: 'history',
schema: {
name: 'browser_navigate_forward',
description: 'Go forward to the next page',

View File

@ -27,6 +27,7 @@ import type { Tool } from './tool';
const pdfSchema = z.object({});
const pdf: Tool = {
capability: 'pdf',
schema: {
name: 'browser_pdf_save',
description: 'Save page as PDF',

View File

@ -20,6 +20,7 @@ import { zodToJsonSchema } from 'zod-to-json-schema';
import type { Tool } from './tool';
const screenshot: Tool = {
capability: 'core',
schema: {
name: 'browser_screen_capture',
description: 'Take a screenshot of the current page',
@ -45,6 +46,7 @@ const moveMouseSchema = elementSchema.extend({
});
const moveMouse: Tool = {
capability: 'core',
schema: {
name: 'browser_screen_move_mouse',
description: 'Move mouse to a given position',
@ -67,6 +69,7 @@ const clickSchema = elementSchema.extend({
});
const click: Tool = {
capability: 'core',
schema: {
name: 'browser_screen_click',
description: 'Click left mouse button',
@ -93,6 +96,7 @@ const dragSchema = elementSchema.extend({
});
const drag: Tool = {
capability: 'core',
schema: {
name: 'browser_screen_drag',
description: 'Drag left mouse button',
@ -118,6 +122,7 @@ const typeSchema = z.object({
});
const type: Tool = {
capability: 'core',
schema: {
name: 'browser_screen_type',
description: 'Type text',

View File

@ -21,6 +21,7 @@ import type * as playwright from 'playwright';
import type { Tool } from './tool';
const snapshot: Tool = {
capability: 'core',
schema: {
name: 'browser_snapshot',
description: 'Capture accessibility snapshot of the current page, this is better than screenshot',
@ -38,6 +39,7 @@ const elementSchema = z.object({
});
const click: Tool = {
capability: 'core',
schema: {
name: 'browser_click',
description: 'Perform click on a web page',
@ -63,6 +65,7 @@ const dragSchema = z.object({
});
const drag: Tool = {
capability: 'core',
schema: {
name: 'browser_drag',
description: 'Perform drag and drop between two elements',
@ -82,6 +85,7 @@ const drag: Tool = {
};
const hover: Tool = {
capability: 'core',
schema: {
name: 'browser_hover',
description: 'Hover over element on page',
@ -106,6 +110,7 @@ const typeSchema = elementSchema.extend({
});
const type: Tool = {
capability: 'core',
schema: {
name: 'browser_type',
description: 'Type text into editable element',
@ -133,6 +138,7 @@ const selectOptionSchema = elementSchema.extend({
});
const selectOption: Tool = {
capability: 'core',
schema: {
name: 'browser_select_option',
description: 'Select an option in a dropdown',
@ -155,6 +161,7 @@ const screenshotSchema = z.object({
});
const screenshot: Tool = {
capability: 'core',
schema: {
name: 'browser_take_screenshot',
description: `Take a screenshot of the current page. You can't perform actions based on the screenshot, use browser_snapshot for actions.`,

View File

@ -20,6 +20,7 @@ import { zodToJsonSchema } from 'zod-to-json-schema';
import type { ToolFactory, Tool } from './tool';
const listTabs: Tool = {
capability: 'tabs',
schema: {
name: 'browser_tab_list',
description: 'List browser tabs',
@ -40,6 +41,7 @@ const selectTabSchema = z.object({
});
const selectTab: ToolFactory = captureSnapshot => ({
capability: 'tabs',
schema: {
name: 'browser_tab_select',
description: 'Select a tab by index',
@ -58,6 +60,7 @@ const newTabSchema = z.object({
});
const newTab: Tool = {
capability: 'tabs',
schema: {
name: 'browser_tab_new',
description: 'Open a new tab',
@ -77,6 +80,7 @@ const closeTabSchema = z.object({
});
const closeTab: ToolFactory = captureSnapshot => ({
capability: 'tabs',
schema: {
name: 'browser_tab_close',
description: 'Close a tab',

View File

@ -18,6 +18,8 @@ import type { ImageContent, TextContent } from '@modelcontextprotocol/sdk/types'
import type { JsonSchema7Type } from 'zod-to-json-schema';
import type { Context } from '../context';
export type ToolCapability = 'core' | 'tabs' | 'pdf' | 'history' | 'wait' | 'files' | 'install';
export type ToolSchema = {
name: string;
description: string;
@ -30,6 +32,7 @@ export type ToolResult = {
};
export type Tool = {
capability: ToolCapability;
schema: ToolSchema;
handle: (context: Context, params?: Record<string, any>) => Promise<ToolResult>;
};

View File

@ -15,69 +15,9 @@
*/
import fs from 'fs/promises';
import { spawn } from 'node:child_process';
import path from 'node:path';
import { test, expect } from './fixtures';
test('test tool list', async ({ client, visionClient }) => {
const { tools } = await client.listTools();
expect(new Set(tools.map(t => t.name))).toEqual(new Set([
'browser_click',
'browser_drag',
'browser_hover',
'browser_select_option',
'browser_type',
'browser_choose_file',
'browser_close',
'browser_install',
'browser_navigate_back',
'browser_navigate_forward',
'browser_navigate',
'browser_pdf_save',
'browser_press_key',
'browser_snapshot',
'browser_tab_close',
'browser_tab_list',
'browser_tab_new',
'browser_tab_select',
'browser_take_screenshot',
'browser_wait',
]));
const { tools: visionTools } = await visionClient.listTools();
expect(new Set(visionTools.map(t => t.name))).toEqual(new Set([
'browser_choose_file',
'browser_close',
'browser_install',
'browser_navigate_back',
'browser_navigate_forward',
'browser_navigate',
'browser_pdf_save',
'browser_press_key',
'browser_screen_capture',
'browser_screen_click',
'browser_screen_drag',
'browser_screen_move_mouse',
'browser_screen_type',
'browser_tab_close',
'browser_tab_list',
'browser_tab_new',
'browser_tab_select',
'browser_wait',
]));
});
test('test resources list', async ({ client }) => {
const { resources } = await client.listResources();
expect(resources).toEqual([
expect.objectContaining({
uri: 'browser://console',
mimeType: 'text/plain',
}),
]);
});
test('test browser_navigate', async ({ client }) => {
test('browser_navigate', async ({ client }) => {
expect(await client.callTool({
name: 'browser_navigate',
arguments: {
@ -96,7 +36,7 @@ Navigated to data:text/html,<html><title>Title</title><body>Hello, world!</body>
);
});
test('test browser_click', async ({ client }) => {
test('browser_click', async ({ client }) => {
await client.callTool({
name: 'browser_navigate',
arguments: {
@ -121,36 +61,8 @@ test('test browser_click', async ({ client }) => {
`);
});
test('test reopen browser', async ({ client }) => {
await client.callTool({
name: 'browser_navigate',
arguments: {
url: 'data:text/html,<html><title>Title</title><body>Hello, world!</body></html>',
},
});
expect(await client.callTool({
name: 'browser_close',
})).toHaveTextContent('Page closed');
expect(await client.callTool({
name: 'browser_navigate',
arguments: {
url: 'data:text/html,<html><title>Title</title><body>Hello, world!</body></html>',
},
})).toHaveTextContent(`
Navigated to data:text/html,<html><title>Title</title><body>Hello, world!</body></html>
- Page URL: data:text/html,<html><title>Title</title><body>Hello, world!</body></html>
- Page Title: Title
- Page Snapshot
\`\`\`yaml
- text: Hello, world!
\`\`\`
`);
});
test('single option', async ({ client }) => {
test('browser_select_option', async ({ client }) => {
await client.callTool({
name: 'browser_navigate',
arguments: {
@ -178,7 +90,7 @@ test('single option', async ({ client }) => {
`);
});
test('multiple option', async ({ client }) => {
test('browser_select_option (multiple)', async ({ client }) => {
await client.callTool({
name: 'browser_navigate',
arguments: {
@ -207,51 +119,7 @@ test('multiple option', async ({ client }) => {
`);
});
test('browser://console', async ({ client }) => {
await client.callTool({
name: 'browser_navigate',
arguments: {
url: 'data:text/html,<html><script>console.log("Hello, world!");console.error("Error"); </script></html>',
},
});
const resource = await client.readResource({
uri: 'browser://console',
});
expect(resource.contents).toEqual([{
uri: 'browser://console',
mimeType: 'text/plain',
text: '[LOG] Hello, world!\n[ERROR] Error',
}]);
});
test('stitched aria frames', async ({ client }) => {
expect(await client.callTool({
name: 'browser_navigate',
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
- heading "Hello" [level=1] [ref=s1e3]
- iframe [ref=s1e4]:
- button "World" [ref=f1s1e3]
- main [ref=f1s1e4]:
- iframe [ref=f1s1e5]:
- paragraph [ref=f2s1e3]: Nested
\`\`\`
`);
expect(await client.callTool({
name: 'browser_click',
arguments: {
element: 'World',
ref: 'f1s1e3',
},
})).toContainTextContent('Clicked "World"');
});
test('browser_choose_file', async ({ client }) => {
test('browser_file_upload', async ({ client }) => {
expect(await client.callTool({
name: 'browser_navigate',
arguments: {
@ -265,20 +133,20 @@ test('browser_choose_file', async ({ client }) => {
element: 'Textbox',
ref: 's1e3',
},
})).toContainTextContent('There is a file chooser visible that requires browser_choose_file to be called');
})).toContainTextContent('There is a file chooser visible that requires browser_file_upload to be called');
const filePath = test.info().outputPath('test.txt');
await fs.writeFile(filePath, 'Hello, world!');
{
const response = await client.callTool({
name: 'browser_choose_file',
name: 'browser_file_upload',
arguments: {
paths: [filePath],
},
});
expect(response).not.toContainTextContent('There is a file chooser visible that requires browser_choose_file to be called');
expect(response).not.toContainTextContent('There is a file chooser visible that requires browser_file_upload to be called');
expect(response).toContainTextContent('textbox [ref=s3e3]: C:\\fakepath\\test.txt');
}
@ -291,7 +159,7 @@ test('browser_choose_file', async ({ client }) => {
},
});
expect(response).toContainTextContent('There is a file chooser visible that requires browser_choose_file to be called');
expect(response).toContainTextContent('There is a file chooser visible that requires browser_file_upload to be called');
expect(response).toContainTextContent('button "Button" [ref=s4e4]');
}
@ -304,89 +172,11 @@ test('browser_choose_file', async ({ client }) => {
},
});
expect(response, 'not submitting browser_choose_file dismisses file chooser').not.toContainTextContent('There is a file chooser visible that requires browser_choose_file to be called');
expect(response, 'not submitting browser_file_upload dismisses file chooser').not.toContainTextContent('There is a file chooser visible that requires browser_file_upload to be called');
}
});
test('sse transport', async () => {
const cp = spawn('node', [path.join(__dirname, '../cli.js'), '--port', '0'], { stdio: 'pipe' });
try {
let stdout = '';
const url = await new Promise<string>(resolve => cp.stdout?.on('data', data => {
stdout += data.toString();
const match = stdout.match(/Listening on (http:\/\/.*)/);
if (match)
resolve(match[1]);
}));
// need dynamic import b/c of some ESM nonsense
const { SSEClientTransport } = await import('@modelcontextprotocol/sdk/client/sse.js');
const { Client } = await import('@modelcontextprotocol/sdk/client/index.js');
const transport = new SSEClientTransport(new URL(url));
const client = new Client({ name: 'test', version: '1.0.0' });
await client.connect(transport);
await client.ping();
} finally {
cp.kill();
}
});
test('cdp server', async ({ cdpEndpoint, startClient }) => {
const client = await startClient({ args: [`--cdp-endpoint=${cdpEndpoint}`] });
expect(await client.callTool({
name: 'browser_navigate',
arguments: {
url: 'data:text/html,<html><title>Title</title><body>Hello, world!</body></html>',
},
})).toHaveTextContent(`
Navigated to data:text/html,<html><title>Title</title><body>Hello, world!</body></html>
- Page URL: data:text/html,<html><title>Title</title><body>Hello, world!</body></html>
- Page Title: Title
- Page Snapshot
\`\`\`yaml
- text: Hello, world!
\`\`\`
`
);
});
test('save as pdf', async ({ client }) => {
expect(await client.callTool({
name: 'browser_navigate',
arguments: {
url: 'data:text/html,<html><title>Title</title><body>Hello, world!</body></html>',
},
})).toHaveTextContent(`
Navigated to data:text/html,<html><title>Title</title><body>Hello, world!</body></html>
- Page URL: data:text/html,<html><title>Title</title><body>Hello, world!</body></html>
- Page Title: Title
- Page Snapshot
\`\`\`yaml
- text: Hello, world!
\`\`\`
`
);
const response = await client.callTool({
name: 'browser_pdf_save',
});
expect(response).toHaveTextContent(/^Saved as.*page-[^:]+.pdf$/);
});
test('executable path', async ({ startClient }) => {
const client = await startClient({ args: [`--executable-path=bogus`] });
const response = await client.callTool({
name: 'browser_navigate',
arguments: {
url: 'data:text/html,<html><title>Title</title><body>Hello, world!</body></html>',
},
});
expect(response).toContainTextContent(`executable doesn't exist`);
});
test('fill in text', async ({ client }) => {
test('browser_type', async ({ client }) => {
await client.callTool({
name: 'browser_navigate',
arguments: {
@ -412,7 +202,7 @@ test('fill in text', async ({ client }) => {
}]);
});
test('type slowly', async ({ client }) => {
test('browser_type (slowly)', async ({ client }) => {
await client.callTool({
name: 'browser_navigate',
arguments: {

View File

@ -0,0 +1,92 @@
/**
* Copyright (c) Microsoft Corporation.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { test, expect } from './fixtures';
test('test snapshot tool list', async ({ client }) => {
const { tools } = await client.listTools();
expect(new Set(tools.map(t => t.name))).toEqual(new Set([
'browser_click',
'browser_drag',
'browser_file_upload',
'browser_hover',
'browser_select_option',
'browser_type',
'browser_close',
'browser_install',
'browser_navigate_back',
'browser_navigate_forward',
'browser_navigate',
'browser_pdf_save',
'browser_press_key',
'browser_snapshot',
'browser_tab_close',
'browser_tab_list',
'browser_tab_new',
'browser_tab_select',
'browser_take_screenshot',
'browser_wait',
]));
});
test('test vision tool list', async ({ visionClient }) => {
const { tools: visionTools } = await visionClient.listTools();
expect(new Set(visionTools.map(t => t.name))).toEqual(new Set([
'browser_close',
'browser_file_upload',
'browser_install',
'browser_navigate_back',
'browser_navigate_forward',
'browser_navigate',
'browser_pdf_save',
'browser_press_key',
'browser_screen_capture',
'browser_screen_click',
'browser_screen_drag',
'browser_screen_move_mouse',
'browser_screen_type',
'browser_tab_close',
'browser_tab_list',
'browser_tab_new',
'browser_tab_select',
'browser_wait',
]));
});
test('test resources list', async ({ client }) => {
const { resources } = await client.listResources();
expect(resources).toEqual([
expect.objectContaining({
uri: 'browser://console',
mimeType: 'text/plain',
}),
]);
});
test('test capabilities', async ({ startClient }) => {
const client = await startClient({
args: ['--caps="core"'],
});
const { tools } = await client.listTools();
const toolNames = tools.map(t => t.name);
expect(toolNames).not.toContain('browser_file_upload');
expect(toolNames).not.toContain('browser_pdf_save');
expect(toolNames).not.toContain('browser_screen_capture');
expect(toolNames).not.toContain('browser_screen_click');
expect(toolNames).not.toContain('browser_screen_drag');
expect(toolNames).not.toContain('browser_screen_move_mouse');
expect(toolNames).not.toContain('browser_screen_type');
});

37
tests/cdp.spec.ts Normal file
View File

@ -0,0 +1,37 @@
/**
* Copyright (c) Microsoft Corporation.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { test, expect } from './fixtures';
test('cdp server', async ({ cdpEndpoint, startClient }) => {
const client = await startClient({ args: [`--cdp-endpoint=${cdpEndpoint}`] });
expect(await client.callTool({
name: 'browser_navigate',
arguments: {
url: 'data:text/html,<html><title>Title</title><body>Hello, world!</body></html>',
},
})).toHaveTextContent(`
Navigated to data:text/html,<html><title>Title</title><body>Hello, world!</body></html>
- Page URL: data:text/html,<html><title>Title</title><body>Hello, world!</body></html>
- Page Title: Title
- Page Snapshot
\`\`\`yaml
- text: Hello, world!
\`\`\`
`
);
});

35
tests/console.spec.ts Normal file
View File

@ -0,0 +1,35 @@
/**
* Copyright (c) Microsoft Corporation.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { test, expect } from './fixtures';
test('browser://console', async ({ client }) => {
await client.callTool({
name: 'browser_navigate',
arguments: {
url: 'data:text/html,<html><script>console.log("Hello, world!");console.error("Error"); </script></html>',
},
});
const resource = await client.readResource({
uri: 'browser://console',
});
expect(resource.contents).toEqual([{
uri: 'browser://console',
mimeType: 'text/plain',
text: '[LOG] Hello, world!\n[ERROR] Error',
}]);
});

View File

@ -24,7 +24,7 @@ import { Client } from '@modelcontextprotocol/sdk/client/index.js';
type Fixtures = {
client: Client;
visionClient: Client;
startClient: (options?: { args?: string[], vision?: boolean }) => Promise<Client>;
startClient: (options?: { args?: string[] }) => Promise<Client>;
wsEndpoint: string;
cdpEndpoint: string;
};
@ -36,7 +36,7 @@ export const test = baseTest.extend<Fixtures>({
},
visionClient: async ({ startClient }, use) => {
await use(await startClient({ vision: true }));
await use(await startClient({ args: ['--vision'] }));
},
startClient: async ({ }, use, testInfo) => {
@ -45,8 +45,6 @@ export const test = baseTest.extend<Fixtures>({
use(async options => {
const args = ['--headless', '--user-data-dir', userDataDir];
if (options?.vision)
args.push('--vision');
if (options?.args)
args.push(...options.args);
const transport = new StdioClientTransport({

43
tests/iframes.spec.ts Normal file
View File

@ -0,0 +1,43 @@
/**
* Copyright (c) Microsoft Corporation.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { test, expect } from './fixtures';
test('stitched aria frames', async ({ client }) => {
expect(await client.callTool({
name: 'browser_navigate',
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
- heading "Hello" [level=1] [ref=s1e3]
- iframe [ref=s1e4]:
- button "World" [ref=f1s1e3]
- main [ref=f1s1e4]:
- iframe [ref=f1s1e5]:
- paragraph [ref=f2s1e3]: Nested
\`\`\`
`);
expect(await client.callTool({
name: 'browser_click',
arguments: {
element: 'World',
ref: 'f1s1e3',
},
})).toContainTextContent('Clicked "World"');
});

57
tests/launch.spec.ts Normal file
View File

@ -0,0 +1,57 @@
/**
* Copyright (c) Microsoft Corporation.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { test, expect } from './fixtures';
test('test reopen browser', async ({ client }) => {
await client.callTool({
name: 'browser_navigate',
arguments: {
url: 'data:text/html,<html><title>Title</title><body>Hello, world!</body></html>',
},
});
expect(await client.callTool({
name: 'browser_close',
})).toHaveTextContent('Page closed');
expect(await client.callTool({
name: 'browser_navigate',
arguments: {
url: 'data:text/html,<html><title>Title</title><body>Hello, world!</body></html>',
},
})).toHaveTextContent(`
Navigated to data:text/html,<html><title>Title</title><body>Hello, world!</body></html>
- Page URL: data:text/html,<html><title>Title</title><body>Hello, world!</body></html>
- Page Title: Title
- Page Snapshot
\`\`\`yaml
- text: Hello, world!
\`\`\`
`);
});
test('executable path', async ({ startClient }) => {
const client = await startClient({ args: [`--executable-path=bogus`] });
const response = await client.callTool({
name: 'browser_navigate',
arguments: {
url: 'data:text/html,<html><title>Title</title><body>Hello, world!</body></html>',
},
});
expect(response).toContainTextContent(`executable doesn't exist`);
});

55
tests/pdf.spec.ts Normal file
View File

@ -0,0 +1,55 @@
/**
* Copyright (c) Microsoft Corporation.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { test, expect } from './fixtures';
test('save as pdf unavailable', async ({ startClient }) => {
const client = await startClient({ args: ['--caps="no-pdf"'] });
await client.callTool({
name: 'browser_navigate',
arguments: {
url: 'data:text/html,<html><title>Title</title><body>Hello, world!</body></html>',
},
});
expect(await client.callTool({
name: 'browser_pdf_save',
})).toHaveTextContent(/Tool \"browser_pdf_save\" not found/);
});
test('save as pdf', async ({ client }) => {
expect(await client.callTool({
name: 'browser_navigate',
arguments: {
url: 'data:text/html,<html><title>Title</title><body>Hello, world!</body></html>',
},
})).toHaveTextContent(`
Navigated to data:text/html,<html><title>Title</title><body>Hello, world!</body></html>
- Page URL: data:text/html,<html><title>Title</title><body>Hello, world!</body></html>
- Page Title: Title
- Page Snapshot
\`\`\`yaml
- text: Hello, world!
\`\`\`
`
);
const response = await client.callTool({
name: 'browser_pdf_save',
});
expect(response).toHaveTextContent(/^Saved as.*page-[^:]+.pdf$/);
});

42
tests/sse.spec.ts Normal file
View File

@ -0,0 +1,42 @@
/**
* Copyright (c) Microsoft Corporation.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { spawn } from 'node:child_process';
import path from 'node:path';
import { test } from './fixtures';
test('sse transport', async () => {
const cp = spawn('node', [path.join(__dirname, '../cli.js'), '--port', '0'], { stdio: 'pipe' });
try {
let stdout = '';
const url = await new Promise<string>(resolve => cp.stdout?.on('data', data => {
stdout += data.toString();
const match = stdout.match(/Listening on (http:\/\/.*)/);
if (match)
resolve(match[1]);
}));
// need dynamic import b/c of some ESM nonsense
const { SSEClientTransport } = await import('@modelcontextprotocol/sdk/client/sse.js');
const { Client } = await import('@modelcontextprotocol/sdk/client/index.js');
const transport = new SSEClientTransport(new URL(url));
const client = new Client({ name: 'test', version: '1.0.0' });
await client.connect(transport);
await client.ping();
} finally {
cp.kill();
}
});