diff --git a/README.md b/README.md index 3919fda..d56fdc3 100644 --- a/README.md +++ b/README.md @@ -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 `: Comma-separated list of capabilities to enable, possible values: tabs, pdf, history, wait, files, install. Default is all. - `--cdp-endpoint `: CDP endpoint to connect to - `--executable-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. diff --git a/index.d.ts b/index.d.ts index c315dfe..01e49c0 100644 --- a/index.d.ts +++ b/index.d.ts @@ -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; diff --git a/src/context.ts b/src/context.ts index 4c8fc02..64b56f6 100644 --- a/src/context.ts +++ b/src/context.ts @@ -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); diff --git a/src/index.ts b/src/index.ts index 0be508f..2d28e72 100644 --- a/src/index.ts +++ b/src/index.ts @@ -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, diff --git a/src/program.ts b/src/program.ts index bdf9fea..5272b61 100644 --- a/src/program.ts +++ b/src/program.ts @@ -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 or chrome channel to use, possible values: chrome, firefox, webkit, msedge.') + .option('--caps ', 'Comma-separated list of capabilities to enable, possible values: tabs, pdf, history, wait, files, install. Default is all.') .option('--cdp-endpoint ', 'CDP endpoint to connect to.') .option('--executable-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); diff --git a/src/tools/common.ts b/src/tools/common.ts index 51a42c6..93e6650 100644 --- a/src/tools/common.ts +++ b/src/tools/common.ts @@ -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', diff --git a/src/tools/fileChooser.ts b/src/tools/files.ts similarity index 78% rename from src/tools/fileChooser.ts rename to src/tools/files.ts index 865f480..add4131 100644 --- a/src/tools/fileChooser.ts +++ b/src/tools/files.ts @@ -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), ]; diff --git a/src/tools/install.ts b/src/tools/install.ts index 9998253..3e1531b 100644 --- a/src/tools/install.ts +++ b/src/tools/install.ts @@ -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.', diff --git a/src/tools/keyboard.ts b/src/tools/keyboard.ts index c06974e..aaf9815 100644 --- a/src/tools/keyboard.ts +++ b/src/tools/keyboard.ts @@ -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', diff --git a/src/tools/navigate.ts b/src/tools/navigate.ts index 05647a0..0105ec3 100644 --- a/src/tools/navigate.ts +++ b/src/tools/navigate.ts @@ -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', diff --git a/src/tools/pdf.ts b/src/tools/pdf.ts index 76bacd5..e35fe5c 100644 --- a/src/tools/pdf.ts +++ b/src/tools/pdf.ts @@ -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', diff --git a/src/tools/screen.ts b/src/tools/screen.ts index d9622d7..f8b9a40 100644 --- a/src/tools/screen.ts +++ b/src/tools/screen.ts @@ -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', diff --git a/src/tools/snapshot.ts b/src/tools/snapshot.ts index 8e1cb47..565a9cc 100644 --- a/src/tools/snapshot.ts +++ b/src/tools/snapshot.ts @@ -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.`, diff --git a/src/tools/tabs.ts b/src/tools/tabs.ts index 1add315..16d15da 100644 --- a/src/tools/tabs.ts +++ b/src/tools/tabs.ts @@ -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', diff --git a/src/tools/tool.ts b/src/tools/tool.ts index 19f1b60..877e0ba 100644 --- a/src/tools/tool.ts +++ b/src/tools/tool.ts @@ -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) => Promise; }; diff --git a/tests/basic.spec.ts b/tests/basic.spec.ts index d6e8741..4113f36 100644 --- a/tests/basic.spec.ts +++ b/tests/basic.spec.ts @@ -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,TitleHello, world! ); }); -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,TitleHello, world!', - }, - }); - expect(await client.callTool({ - name: 'browser_close', - })).toHaveTextContent('Page closed'); - - expect(await client.callTool({ - name: 'browser_navigate', - arguments: { - url: 'data:text/html,TitleHello, world!', - }, - })).toHaveTextContent(` -Navigated to data:text/html,TitleHello, world! - -- Page URL: data:text/html,TitleHello, world! -- 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,', - }, - }); - - 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,

Hello

`, - }, - })).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(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,TitleHello, world!', - }, - })).toHaveTextContent(` -Navigated to data:text/html,TitleHello, world! - -- Page URL: data:text/html,TitleHello, world! -- 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,TitleHello, world!', - }, - })).toHaveTextContent(` -Navigated to data:text/html,TitleHello, world! - -- Page URL: data:text/html,TitleHello, world! -- 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,TitleHello, world!', - }, - }); - 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: { diff --git a/tests/capabilities.spec.ts b/tests/capabilities.spec.ts new file mode 100644 index 0000000..8301bc1 --- /dev/null +++ b/tests/capabilities.spec.ts @@ -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'); +}); diff --git a/tests/cdp.spec.ts b/tests/cdp.spec.ts new file mode 100644 index 0000000..89013c5 --- /dev/null +++ b/tests/cdp.spec.ts @@ -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,TitleHello, world!', + }, + })).toHaveTextContent(` +Navigated to data:text/html,TitleHello, world! + +- Page URL: data:text/html,TitleHello, world! +- Page Title: Title +- Page Snapshot +\`\`\`yaml +- text: Hello, world! +\`\`\` +` + ); +}); diff --git a/tests/console.spec.ts b/tests/console.spec.ts new file mode 100644 index 0000000..947eea7 --- /dev/null +++ b/tests/console.spec.ts @@ -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,', + }, + }); + + 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', + }]); +}); diff --git a/tests/fixtures.ts b/tests/fixtures.ts index 45a3aa5..9caab25 100644 --- a/tests/fixtures.ts +++ b/tests/fixtures.ts @@ -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; + startClient: (options?: { args?: string[] }) => Promise; wsEndpoint: string; cdpEndpoint: string; }; @@ -36,7 +36,7 @@ export const test = baseTest.extend({ }, 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({ 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({ diff --git a/tests/iframes.spec.ts b/tests/iframes.spec.ts new file mode 100644 index 0000000..dce8ced --- /dev/null +++ b/tests/iframes.spec.ts @@ -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,

Hello

`, + }, + })).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"'); +}); diff --git a/tests/launch.spec.ts b/tests/launch.spec.ts new file mode 100644 index 0000000..10d2099 --- /dev/null +++ b/tests/launch.spec.ts @@ -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,TitleHello, world!', + }, + }); + + expect(await client.callTool({ + name: 'browser_close', + })).toHaveTextContent('Page closed'); + + expect(await client.callTool({ + name: 'browser_navigate', + arguments: { + url: 'data:text/html,TitleHello, world!', + }, + })).toHaveTextContent(` +Navigated to data:text/html,TitleHello, world! + +- Page URL: data:text/html,TitleHello, world! +- 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,TitleHello, world!', + }, + }); + expect(response).toContainTextContent(`executable doesn't exist`); +}); diff --git a/tests/pdf.spec.ts b/tests/pdf.spec.ts new file mode 100644 index 0000000..3890ed8 --- /dev/null +++ b/tests/pdf.spec.ts @@ -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,TitleHello, world!', + }, + }); + + 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,TitleHello, world!', + }, + })).toHaveTextContent(` +Navigated to data:text/html,TitleHello, world! + +- Page URL: data:text/html,TitleHello, world! +- 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$/); +}); diff --git a/tests/sse.spec.ts b/tests/sse.spec.ts new file mode 100644 index 0000000..ad627ef --- /dev/null +++ b/tests/sse.spec.ts @@ -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(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(); + } +});