diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 25103c1..2c28167 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -7,29 +7,79 @@ on: branches: [ main ] jobs: - build-and-test: + lint: runs-on: ubuntu-latest - steps: - uses: actions/checkout@v4 - - name: Use Node.js 18 uses: actions/setup-node@v4 with: node-version: '18' cache: 'npm' - - name: Install dependencies run: npm ci - - - name: Run linting + - run: npm run build + - name: Run ESLint run: npm run lint + - name: Ensure no changes + run: git diff --exit-code + test: + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, macos-latest, windows-latest] + runs-on: ${{ matrix.os }} + steps: + - uses: actions/checkout@v4 + - name: Use Node.js 18 + uses: actions/setup-node@v4 + with: + # https://github.com/microsoft/playwright-mcp/issues/344 + node-version: '18.19' + cache: 'npm' + - name: Install dependencies + run: npm ci + - name: Playwright install + run: npx playwright install --with-deps + - name: Install MS Edge + # MS Edge is not preinstalled on macOS runners. + if: ${{ matrix.os == 'macos-latest' }} + run: npx playwright install msedge - name: Build run: npm run build - - - name: Install Playwright browsers - run: npx playwright install --with-deps - - name: Run tests run: npm test + + test_docker: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Use Node.js 18 + uses: actions/setup-node@v4 + with: + node-version: '18' + cache: 'npm' + - name: Install dependencies + run: npm ci + - name: Playwright install + run: npx playwright install --with-deps chromium + - name: Build + run: npm run build + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + - name: Build and push + uses: docker/build-push-action@v6 + with: + tags: playwright-mcp-dev:latest + cache-from: type=gha + cache-to: type=gha,mode=max + load: true + - name: Run tests + shell: bash + run: | + # Used for the Docker tests to share the test-results folder with the container. + umask 0000 + npm run test -- --project=chromium-docker + env: + MCP_IN_DOCKER: 1 diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 7e6b02f..503702b 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -5,6 +5,9 @@ on: jobs: publish-npm: runs-on: ubuntu-latest + permissions: + contents: read + id-token: write # Needed for npm provenance steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 @@ -12,9 +15,41 @@ jobs: node-version: 18 registry-url: https://registry.npmjs.org/ - run: npm ci + - run: npx playwright install --with-deps - run: npm run build - run: npm run lint - - run: npm run test + - run: npm run ctest - run: npm publish --provenance env: - NODE_AUTH_TOKEN: ${{secrets.NPM_TOKEN}} + NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} + + publish-docker: + runs-on: ubuntu-latest + permissions: + contents: read + id-token: write # Needed for OIDC login to Azure + environment: allow-publishing-docker-to-acr + steps: + - uses: actions/checkout@v4 + - name: Set up QEMU # Needed for multi-platform builds (e.g., arm64 on amd64 runner) + uses: docker/setup-qemu-action@v3 + - name: Set up Docker Buildx # Needed for multi-platform builds + uses: docker/setup-buildx-action@v3 + - name: Azure Login via OIDC + uses: azure/login@v2 + with: + client-id: ${{ secrets.AZURE_DOCKER_CLIENT_ID }} + tenant-id: ${{ secrets.AZURE_DOCKER_TENANT_ID }} + subscription-id: ${{ secrets.AZURE_DOCKER_SUBSCRIPTION_ID }} + - name: Login to ACR + run: az acr login --name playwright + - name: Build and push Docker image + uses: docker/build-push-action@v6 + with: + context: . + file: ./Dockerfile # Adjust path if your Dockerfile is elsewhere + platforms: linux/amd64,linux/arm64 + push: true + tags: | + playwright.azurecr.io/public/playwright/mcp:${{ github.event.release.tag_name }} + playwright.azurecr.io/public/playwright/mcp:latest diff --git a/.gitignore b/.gitignore index 7e00484..1089cef 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,8 @@ lib/ node_modules/ test-results/ +playwright-report/ +.vscode/mcp.json + +.idea +.DS_Store diff --git a/.npmignore b/.npmignore index f29dce2..a2846ba 100644 --- a/.npmignore +++ b/.npmignore @@ -4,3 +4,4 @@ LICENSE !lib/**/*.js !cli.js !index.* +!config.d.ts diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..556ea6a --- /dev/null +++ b/Dockerfile @@ -0,0 +1,69 @@ +ARG PLAYWRIGHT_BROWSERS_PATH=/ms-playwright + +# ------------------------------ +# Base +# ------------------------------ +# Base stage: Contains only the minimal dependencies required for runtime +# (node_modules and Playwright system dependencies) +FROM node:22-bookworm-slim AS base + +ARG PLAYWRIGHT_BROWSERS_PATH +ENV PLAYWRIGHT_BROWSERS_PATH=${PLAYWRIGHT_BROWSERS_PATH} + +# Set the working directory +WORKDIR /app + +RUN --mount=type=cache,target=/root/.npm,sharing=locked,id=npm-cache \ + --mount=type=bind,source=package.json,target=package.json \ + --mount=type=bind,source=package-lock.json,target=package-lock.json \ + npm ci --omit=dev && \ + # Install system dependencies for playwright + npx -y playwright-core install-deps chromium + +# ------------------------------ +# Builder +# ------------------------------ +FROM base AS builder + +RUN --mount=type=cache,target=/root/.npm,sharing=locked,id=npm-cache \ + --mount=type=bind,source=package.json,target=package.json \ + --mount=type=bind,source=package-lock.json,target=package-lock.json \ + npm ci + +# Copy the rest of the app +COPY *.json *.js *.ts . +COPY src src/ + +# Build the app +RUN npm run build + +# ------------------------------ +# Browser +# ------------------------------ +# Cache optimization: +# - Browser is downloaded only when node_modules or Playwright system dependencies change +# - Cache is reused when only source code changes +FROM base AS browser + +RUN npx -y playwright-core install --no-shell chromium + +# ------------------------------ +# Runtime +# ------------------------------ +FROM base + +ARG PLAYWRIGHT_BROWSERS_PATH +ARG USERNAME=node +ENV NODE_ENV=production + +# Set the correct ownership for the runtime user on production `node_modules` +RUN chown -R ${USERNAME}:${USERNAME} node_modules + +USER ${USERNAME} + +COPY --from=browser --chown=${USERNAME}:${USERNAME} ${PLAYWRIGHT_BROWSERS_PATH} ${PLAYWRIGHT_BROWSERS_PATH} +COPY --chown=${USERNAME}:${USERNAME} cli.js package.json ./ +COPY --from=builder --chown=${USERNAME}:${USERNAME} /app/lib /app/lib + +# Run in headless and only with chromium (other browsers need more dependencies not included in this image) +ENTRYPOINT ["node", "cli.js", "--headless", "--browser", "chromium", "--no-sandbox"] diff --git a/README.md b/README.md index 3f2b70e..99e52d9 100644 --- a/README.md +++ b/README.md @@ -4,18 +4,22 @@ A Model Context Protocol (MCP) server that provides browser automation capabilit ### Key Features -- **Fast and lightweight**: Uses Playwright's accessibility tree, not pixel-based input. -- **LLM-friendly**: No vision models needed, operates purely on structured data. -- **Deterministic tool application**: Avoids ambiguity common with screenshot-based approaches. +- **Fast and lightweight**. Uses Playwright's accessibility tree, not pixel-based input. +- **LLM-friendly**. No vision models needed, operates purely on structured data. +- **Deterministic tool application**. Avoids ambiguity common with screenshot-based approaches. -### Use Cases +### Requirements +- Node.js 18 or newer +- VS Code, Cursor, Windsurf, Claude Desktop or any other MCP client -- Web navigation and form-filling -- Data extraction from structured content -- Automated testing driven by LLMs -- General-purpose browser interaction for agents + -### Example config +### Getting started + +First, install the Playwright MCP server with your client. A typical configuration looks like this: ```js { @@ -30,51 +34,163 @@ A Model Context Protocol (MCP) server that provides browser automation capabilit } ``` +[Install in VS Code](https://insiders.vscode.dev/redirect?url=vscode%3Amcp%2Finstall%3F%257B%2522name%2522%253A%2522playwright%2522%252C%2522command%2522%253A%2522npx%2522%252C%2522args%2522%253A%255B%2522%2540playwright%252Fmcp%2540latest%2522%255D%257D) [Install in VS Code Insiders](https://insiders.vscode.dev/redirect?url=vscode-insiders%3Amcp%2Finstall%3F%257B%2522name%2522%253A%2522playwright%2522%252C%2522command%2522%253A%2522npx%2522%252C%2522args%2522%253A%255B%2522%2540playwright%252Fmcp%2540latest%2522%255D%257D) -#### Installation in VS Code - -Install the Playwright MCP server in VS Code using one of these buttons: - - -[Install in VS Code Insiders](https://insiders.vscode.dev/redirect?url=vscode-insiders%3Amcp%2Finstall%3F%257B%2522name%2522%253A%2522playwright%2522%252C%2522command%2522%253A%2522npx%2522%252C%2522args%2522%253A%255B%2522-y%2522%252C%2522%2540playwright%252Fmcp%2540latest%2522%255D%257D) +
Install in VS Code -Alternatively, you can install the Playwright MCP server using the VS Code CLI: +You can also install the Playwright MCP server using the VS Code CLI: ```bash # For VS Code code --add-mcp '{"name":"playwright","command":"npx","args":["@playwright/mcp@latest"]}' ``` -```bash -# For VS Code Insiders -code-insiders --add-mcp '{"name":"playwright","command":"npx","args":["@playwright/mcp@latest"]}' +After installation, the Playwright MCP server will be available for use with your GitHub Copilot agent in VS Code. +
+ +
+Install in Cursor + +Go to `Cursor Settings` -> `MCP` -> `Add new MCP Server`. Name to your liking, use `command` type with the command `npx @playwright/mcp`. You can also verify config or add command like arguments via clicking `Edit`. + +```js +{ + "mcpServers": { + "playwright": { + "command": "npx", + "args": [ + "@playwright/mcp@latest" + ] + } + } +} ``` +
-After installation, the Playwright MCP server will be available for use with your GitHub Copilot agent in VS Code. +
+Install in Windsurf -### User data directory +Follow Windsuff MCP [documentation](https://docs.windsurf.com/windsurf/cascade/mcp). Use following configuration: -Playwright MCP will launch Chrome browser with the new profile, located at +```js +{ + "mcpServers": { + "playwright": { + "command": "npx", + "args": [ + "@playwright/mcp@latest" + ] + } + } +} +``` +
+ +
+Install in Claude Desktop +Follow the MCP install [guide](https://modelcontextprotocol.io/quickstart/user), use following configuration: + +```js +{ + "mcpServers": { + "playwright": { + "command": "npx", + "args": [ + "@playwright/mcp@latest" + ] + } + } +} ``` -- `%USERPROFILE%\AppData\Local\ms-playwright\mcp-chrome-profile` on Windows -- `~/Library/Caches/ms-playwright/mcp-chrome-profile` on macOS -- `~/.cache/ms-playwright/mcp-chrome-profile` on Linux +
+ +### Configuration + +Playwright MCP server supports following arguments. They can be provided in the JSON configuration above, as a part of the `"args"` list: + + + +``` +> npx @playwright/mcp@latest --help + --allowed-origins semicolon-separated list of origins to allow the + browser to request. Default is to allow all. + --blocked-origins semicolon-separated list of origins to block the + browser from requesting. Blocklist is evaluated + before allowlist. If used without the allowlist, + requests not matching the blocklist are still + allowed. + --block-service-workers block service workers + --browser browser or chrome channel to use, possible + values: chrome, firefox, webkit, msedge. + --browser-agent Use browser agent (experimental). + --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. + --config path to the configuration file. + --device device to emulate, for example: "iPhone 15" + --executable-path path to the browser executable. + --headless run browser in headless mode, headed by default + --host host to bind server to. Default is localhost. Use + 0.0.0.0 to bind to all interfaces. + --ignore-https-errors ignore https errors + --isolated keep the browser profile in memory, do not save + it to disk. + --image-responses whether to send image responses to the client. + Can be "allow", "omit", or "auto". Defaults to + "auto", which sends images if the client can + display them. + --no-sandbox disable the sandbox for all process types that + are normally sandboxed. + --output-dir path to the directory for output files. + --port port to listen on for SSE transport. + --proxy-bypass comma-separated domains to bypass proxy, for + example ".com,chromium.org,.domain.com" + --proxy-server specify proxy server, for example + "http://myproxy:3128" or "socks5://myproxy:8080" + --save-trace Whether to save the Playwright Trace of the + session into the output directory. + --storage-state path to the storage state file for isolated + sessions. + --user-agent specify user agent string + --user-data-dir path to the user data directory. If not + specified, a temporary directory will be created. + --viewport-size specify browser viewport size in pixels, for + example "1280, 720" + --vision Run server that uses screenshots (Aria snapshots + are used by default) ``` -All the logged in information will be stored in that profile, you can delete it between sessions if you'dlike to clear the offline state. + + +### User profile +You can run Playwright MCP with persistent profile like a regular browser (default), or in the isolated contexts for the testing sessions. -### Running headless browser (Browser without GUI). +**Persistent profile** -This mode is useful for background or batch operations. +All the logged in information will be stored in the persistent profile, you can delete it between sessions if you'd like to clear the offline state. +Persistent profile is located at the following locations and you can override it with the `--user-data-dir` argument. + +```bash +# Windows +%USERPROFILE%\AppData\Local\ms-playwright\mcp-{channel}-profile + +# macOS +- ~/Library/Caches/ms-playwright/mcp-{channel}-profile + +# Linux +- ~/.cache/ms-playwright/mcp-{channel}-profile +``` + +**Isolated** + +In the isolated mode, each session is started in the isolated profile. Every time you ask MCP to close the browser, +the session is closed and all the storage state for this session is lost. You can provide initial storage state +to the browser via the config's `contextOptions` or via the `--storage-state` argument. Learn more about the storage +state [here](https://playwright.dev/docs/auth). ```js { @@ -83,14 +199,104 @@ This mode is useful for background or batch operations. "command": "npx", "args": [ "@playwright/mcp@latest", - "--headless" + "--isolated", + "--storage-state={path/to/storage.json}" ] } } } ``` -### Running headed browser on Linux w/o DISPLAY +### Configuration file + +The Playwright MCP server can be configured using a JSON configuration file. You can specify the configuration file +using the `--config` command line option: + +```bash +npx @playwright/mcp@latest --config path/to/config.json +``` + +
+Configuration file schema + +```typescript +{ + // Browser configuration + browser?: { + // Browser type to use (chromium, firefox, or webkit) + browserName?: 'chromium' | 'firefox' | 'webkit'; + + // Keep the browser profile in memory, do not save it to disk. + isolated?: boolean; + + // Path to user data directory for browser profile persistence + userDataDir?: string; + + // Browser launch options (see Playwright docs) + // @see https://playwright.dev/docs/api/class-browsertype#browser-type-launch + launchOptions?: { + channel?: string; // Browser channel (e.g. 'chrome') + headless?: boolean; // Run in headless mode + executablePath?: string; // Path to browser executable + // ... other Playwright launch options + }; + + // Browser context options + // @see https://playwright.dev/docs/api/class-browser#browser-new-context + contextOptions?: { + viewport?: { width: number, height: number }; + // ... other Playwright context options + }; + + // CDP endpoint for connecting to existing browser + cdpEndpoint?: string; + + // Remote Playwright server endpoint + remoteEndpoint?: string; + }, + + // Server configuration + server?: { + port?: number; // Port to listen on + host?: string; // Host to bind to (default: localhost) + }, + + // List of enabled capabilities + capabilities?: Array< + 'core' | // Core browser automation + 'tabs' | // Tab management + 'pdf' | // PDF generation + 'history' | // Browser history + 'wait' | // Wait utilities + 'files' | // File handling + 'install' | // Browser installation + 'testing' // Testing + >; + + // Enable vision mode (screenshots instead of accessibility snapshots) + vision?: boolean; + + // Directory for output files + outputDir?: string; + + // Network configuration + network?: { + // List of origins to allow the browser to request. Default is to allow all. Origins matching both `allowedOrigins` and `blockedOrigins` will be blocked. + allowedOrigins?: string[]; + + // List of origins to block the browser to request. Origins matching both `allowedOrigins` and `blockedOrigins` will be blocked. + blockedOrigins?: string[]; + }; + + /** + * Do not send image responses to the client. + */ + noImageResponses?: boolean; +} +``` +
+ +### Standalone MCP server When running headed browser on system w/o display or from worker processes of the IDEs, run the MCP server from environment with the DISPLAY and pass the `--port` flag to enable SSE transport. @@ -111,7 +317,52 @@ And then in MCP client config, set the `url` to the SSE endpoint: } ``` -### Tool Modes +
+Docker + +**NOTE:** The Docker implementation only supports headless chromium at the moment. + +```js +{ + "mcpServers": { + "playwright": { + "command": "docker", + "args": ["run", "-i", "--rm", "--init", "--pull=always", "mcr.microsoft.com/playwright/mcp"] + } + } +} +``` + +You can build the Docker image yourself. + +``` +docker build -t mcr.microsoft.com/playwright/mcp . +``` +
+ +
+Programmatic usage + +```js +import http from 'http'; + +import { createConnection } from '@playwright/mcp'; +import { SSEServerTransport } from '@modelcontextprotocol/sdk/server/sse.js'; + +http.createServer(async (req, res) => { + // ... + + // Creates a headless Playwright MCP server with SSE transport + const connection = await createConnection({ browser: { launchOptions: { headless: true } } }); + const transport = new SSEServerTransport('/messages', res); + await connection.connect(transport); + + // ... +}); +``` +
+ +### Tools The tools are available in two modes: @@ -137,171 +388,371 @@ To use Vision Mode, add the `--vision` flag when starting the server: Vision Mode works best with the computer use models that are able to interact with elements using X Y coordinate space, based on the provided screenshot. -### Programmatic usage with custom transports + -```js -import { createServer } from '@playwright/mcp'; - -// ... +
+Interactions -const server = createServer({ - launchOptions: { headless: true } -}); -transport = new SSEServerTransport("/messages", res); -server.connect(transport); -``` + -### Snapshot Mode - -The Playwright MCP provides a set of tools for browser automation. Here are all available tools: - -- **browser_navigate** - - Description: Navigate to a URL - - Parameters: - - `url` (string): The URL to navigate to - -- **browser_go_back** - - Description: Go back to the previous page +- **browser_snapshot** + - Title: Page snapshot + - Description: Capture accessibility snapshot of the current page, this is better than screenshot - Parameters: None + - Read-only: **true** -- **browser_go_forward** - - Description: Go forward to the next page - - Parameters: None + - **browser_click** + - Title: Click - Description: Perform click on a web page - Parameters: - `element` (string): Human-readable element description used to obtain permission to interact with the element - `ref` (string): Exact target element reference from the page snapshot + - Read-only: **false** -- **browser_hover** - - Description: Hover over element on page - - Parameters: - - `element` (string): Human-readable element description used to obtain permission to interact with the element - - `ref` (string): Exact target element reference from the page snapshot + - **browser_drag** + - Title: Drag mouse - Description: Perform drag and drop between two elements - Parameters: - - `startElement` (string): Human-readable source element description used to obtain permission to interact with the element + - `startElement` (string): Human-readable source element description used to obtain the permission to interact with the element - `startRef` (string): Exact source element reference from the page snapshot - - `endElement` (string): Human-readable target element description used to obtain permission to interact with the element + - `endElement` (string): Human-readable target element description used to obtain the permission to interact with the element - `endRef` (string): Exact target element reference from the page snapshot + - Read-only: **false** + + + +- **browser_hover** + - Title: Hover mouse + - Description: Hover over element on page + - Parameters: + - `element` (string): Human-readable element description used to obtain permission to interact with the element + - `ref` (string): Exact target element reference from the page snapshot + - Read-only: **true** + + - **browser_type** + - Title: Type text - Description: Type text into editable element - Parameters: - `element` (string): Human-readable element description used to obtain permission to interact with the element - `ref` (string): Exact target element reference from the page snapshot - `text` (string): Text to type into the element - - `submit` (boolean): Whether to submit entered text (press Enter after) + - `submit` (boolean, optional): Whether to submit entered text (press Enter after) + - `slowly` (boolean, optional): Whether to type one character at a time. Useful for triggering key handlers in the page. By default entire text is filled in at once. + - Read-only: **false** + + - **browser_select_option** - - Description: Select option in a dropdown + - Title: Select option + - Description: Select an option in a dropdown - Parameters: - `element` (string): Human-readable element description used to obtain permission to interact with the element - `ref` (string): Exact target element reference from the page snapshot - - `values` (array): Array of values to select in the dropdown. + - `values` (array): Array of values to select in the dropdown. This can be a single value or multiple values. + - Read-only: **false** -- **browser_choose_file** - - 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. + - **browser_press_key** + - Title: Press a key - Description: Press a key on the keyboard - Parameters: - `key` (string): Name of the key to press or a character to generate, such as `ArrowLeft` or `a` + - Read-only: **false** -- **browser_snapshot** - - Description: Capture accessibility snapshot of the current page (better than screenshot) - - Parameters: None - -- **browser_save_as_pdf** - - Description: Save page as PDF - - Parameters: None + -- **browser_take_screenshot** - - Description: Capture screenshot of the page +- **browser_wait_for** + - Title: Wait for + - Description: Wait for text to appear or disappear or a specified time to pass - Parameters: - - `raw` (string): Optionally returns lossless PNG screenshot. JPEG by default. + - `time` (number, optional): The time to wait in seconds + - `text` (string, optional): The text to wait for + - `textGone` (string, optional): The text to wait for to disappear + - Read-only: **true** + + -- **browser_wait** - - Description: Wait for a specified time in seconds +- **browser_file_upload** + - Title: Upload files + - Description: Upload one or multiple files - Parameters: - - `time` (number): The time to wait in seconds (capped at 10 seconds) + - `paths` (array): The absolute paths to the files to upload. Can be a single file or multiple files. + - Read-only: **false** -- **browser_close** - - Description: Close the page - - Parameters: None + + +- **browser_handle_dialog** + - Title: Handle a dialog + - Description: Handle a dialog + - Parameters: + - `accept` (boolean): Whether to accept the dialog. + - `promptText` (string, optional): The text of the prompt in case of a prompt dialog. + - Read-only: **false** +
-### Vision Mode +
+Navigation -Vision Mode provides tools for visual-based interactions using screenshots. Here are all available tools: + - **browser_navigate** + - Title: Navigate to a URL - Description: Navigate to a URL - Parameters: - `url` (string): The URL to navigate to + - Read-only: **false** + + -- **browser_go_back** +- **browser_navigate_back** + - Title: Go back - Description: Go back to the previous page - Parameters: None + - Read-only: **true** -- **browser_go_forward** + + +- **browser_navigate_forward** + - Title: Go forward - Description: Go forward to the next page - Parameters: None + - Read-only: **true** + +
+ +
+Resources -- **browser_screenshot** - - Description: Capture screenshot of the current page + + +- **browser_take_screenshot** + - 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. + - `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. + - Read-only: **true** + + + +- **browser_pdf_save** + - Title: Save as PDF + - Description: Save page as PDF + - Parameters: + - `filename` (string, optional): File name to save the pdf to. Defaults to `page-{timestamp}.pdf` if not specified. + - Read-only: **true** + + + +- **browser_network_requests** + - Title: List network requests + - Description: Returns all network requests since loading the page - Parameters: None + - Read-only: **true** + + + +- **browser_console_messages** + - Title: Get console messages + - Description: Returns all console messages + - Parameters: None + - Read-only: **true** + +
+ +
+Utilities -- **browser_move_mouse** - - Description: Move mouse to specified coordinates + + +- **browser_install** + - Title: Install the browser specified in the config + - Description: Install the browser specified in the config. Call this if you get an error about the browser not being installed. + - Parameters: None + - Read-only: **false** + + + +- **browser_close** + - Title: Close browser + - Description: Close the page + - Parameters: None + - Read-only: **true** + + + +- **browser_resize** + - Title: Resize browser window + - Description: Resize the browser window + - Parameters: + - `width` (number): Width of the browser window + - `height` (number): Height of the browser window + - Read-only: **true** + +
+ +
+Tabs + + + +- **browser_tab_list** + - Title: List tabs + - Description: List browser tabs + - Parameters: None + - Read-only: **true** + + + +- **browser_tab_new** + - Title: Open a new tab + - Description: Open a new tab + - Parameters: + - `url` (string, optional): The URL to navigate to in the new tab. If not provided, the new tab will be blank. + - Read-only: **true** + + + +- **browser_tab_select** + - Title: Select a tab + - Description: Select a tab by index + - Parameters: + - `index` (number): The index of the tab to select + - Read-only: **true** + + + +- **browser_tab_close** + - Title: Close a tab + - Description: Close a tab + - Parameters: + - `index` (number, optional): The index of the tab to close. Closes current tab if not provided. + - Read-only: **false** + +
+ +
+Testing + + + +- **browser_generate_playwright_test** + - Title: Generate a Playwright test + - Description: Generate a Playwright test for given scenario + - Parameters: + - `name` (string): The name of the test + - `description` (string): The description of the test + - `steps` (array): The steps of the test + - Read-only: **true** + +
+ +
+Vision mode + + + +- **browser_screen_capture** + - Title: Take a screenshot + - Description: Take a screenshot of the current page + - Parameters: None + - Read-only: **true** + + + +- **browser_screen_move_mouse** + - Title: Move mouse + - Description: Move mouse to a given position - Parameters: + - `element` (string): Human-readable element description used to obtain permission to interact with the element - `x` (number): X coordinate - `y` (number): Y coordinate + - Read-only: **true** -- **browser_click** - - Description: Click at specified coordinates + + +- **browser_screen_click** + - Title: Click + - Description: Click left mouse button - Parameters: - - `x` (number): X coordinate to click at - - `y` (number): Y coordinate to click at + - `element` (string): Human-readable element description used to obtain permission to interact with the element + - `x` (number): X coordinate + - `y` (number): Y coordinate + - Read-only: **false** -- **browser_drag** - - Description: Perform drag and drop operation + + +- **browser_screen_drag** + - Title: Drag mouse + - Description: Drag left mouse button - Parameters: + - `element` (string): Human-readable element description used to obtain permission to interact with the element - `startX` (number): Start X coordinate - `startY` (number): Start Y coordinate - `endX` (number): End X coordinate - `endY` (number): End Y coordinate + - Read-only: **false** -- **browser_type** - - Description: Type text at specified coordinates + + +- **browser_screen_type** + - Title: Type text + - Description: Type text - Parameters: - - `text` (string): Text to type - - `submit` (boolean): Whether to submit entered text (press Enter after) + - `text` (string): Text to type into the element + - `submit` (boolean, optional): Whether to submit entered text (press Enter after) + - Read-only: **false** + + - **browser_press_key** + - Title: Press a key - Description: Press a key on the keyboard - Parameters: - `key` (string): Name of the key to press or a character to generate, such as `ArrowLeft` or `a` + - Read-only: **false** -- **browser_choose_file** - - Description: Choose one or multiple files to upload + + +- **browser_wait_for** + - Title: Wait for + - Description: Wait for text to appear or disappear or a specified time to pass + - Parameters: + - `time` (number, optional): The time to wait in seconds + - `text` (string, optional): The text to wait for + - `textGone` (string, optional): The text to wait for to disappear + - Read-only: **true** + + + +- **browser_file_upload** + - Title: Upload files + - Description: Upload one or multiple files - Parameters: - `paths` (array): The absolute paths to the files to upload. Can be a single file or multiple files. + - Read-only: **false** -- **browser_save_as_pdf** - - Description: Save page as PDF - - Parameters: None + -- **browser_wait** - - Description: Wait for a specified time in seconds +- **browser_handle_dialog** + - Title: Handle a dialog + - Description: Handle a dialog - Parameters: - - `time` (number): The time to wait in seconds (capped at 10 seconds) + - `accept` (boolean): Whether to accept the dialog. + - `promptText` (string, optional): The text of the prompt in case of a prompt dialog. + - Read-only: **false** -- **browser_close** - - Description: Close the page - - Parameters: None +
+ + + diff --git a/cli.js b/cli.js index 95d4dbd..bbda09e 100755 --- a/cli.js +++ b/cli.js @@ -15,4 +15,4 @@ * limitations under the License. */ -require('./lib/program'); +import './lib/program.js'; diff --git a/config.d.ts b/config.d.ts new file mode 100644 index 0000000..a935918 --- /dev/null +++ b/config.d.ts @@ -0,0 +1,128 @@ +/** + * 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 type * as playwright from 'playwright'; + +export type ToolCapability = 'core' | 'tabs' | 'pdf' | 'history' | 'wait' | 'files' | 'install' | 'testing'; + +export type Config = { + /** + * The browser to use. + */ + browser?: { + /** + * Use browser agent (experimental). + */ + browserAgent?: string; + + /** + * The type of browser to use. + */ + browserName?: 'chromium' | 'firefox' | 'webkit'; + + /** + * Keep the browser profile in memory, do not save it to disk. + */ + isolated?: boolean; + + /** + * Path to a user data directory for browser profile persistence. + * Temporary directory is created by default. + */ + userDataDir?: string; + + /** + * Launch options passed to + * @see https://playwright.dev/docs/api/class-browsertype#browser-type-launch-persistent-context + * + * This is useful for settings options like `channel`, `headless`, `executablePath`, etc. + */ + launchOptions?: playwright.LaunchOptions; + + /** + * Context options for the browser context. + * + * This is useful for settings options like `viewport`. + */ + contextOptions?: playwright.BrowserContextOptions; + + /** + * Chrome DevTools Protocol endpoint to connect to an existing browser instance in case of Chromium family browsers. + */ + cdpEndpoint?: string; + + /** + * Remote endpoint to connect to an existing Playwright server. + */ + remoteEndpoint?: string; + }, + + server?: { + /** + * The port to listen on for SSE or MCP transport. + */ + port?: number; + + /** + * The host to bind the server to. Default is localhost. Use 0.0.0.0 to bind to all interfaces. + */ + host?: string; + }, + + /** + * List of enabled tool capabilities. Possible values: + * - 'core': Core browser automation features. + * - 'tabs': Tab management features. + * - 'pdf': PDF generation and manipulation. + * - 'history': Browser history access. + * - 'wait': Wait and timing utilities. + * - 'files': File upload/download support. + * - 'install': Browser installation utilities. + */ + capabilities?: ToolCapability[]; + + /** + * Run server that uses screenshots (Aria snapshots are used by default). + */ + vision?: boolean; + + /** + * Whether to save the Playwright trace of the session into the output directory. + */ + saveTrace?: boolean; + + /** + * The directory to save output files. + */ + outputDir?: string; + + network?: { + /** + * List of origins to allow the browser to request. Default is to allow all. Origins matching both `allowedOrigins` and `blockedOrigins` will be blocked. + */ + allowedOrigins?: string[]; + + /** + * List of origins to block the browser to request. Origins matching both `allowedOrigins` and `blockedOrigins` will be blocked. + */ + blockedOrigins?: string[]; + }; + + /** + * Whether to send image responses to the client. Can be "allow", "omit", or "auto". Defaults to "auto", which sends images if the client can display them. + */ + imageResponses?: 'allow' | 'omit' | 'auto'; +}; diff --git a/eslint.config.mjs b/eslint.config.mjs index abedf47..eda1eff 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -33,6 +33,8 @@ const plugins = { }; export const baseRules = { + "import/extensions": ["error", "ignorePackages", {ts: "always"}], + "@typescript-eslint/no-floating-promises": "error", "@typescript-eslint/no-unused-vars": [ 2, { args: "none", caughtErrors: "none" }, @@ -178,12 +180,16 @@ export const baseRules = { // react "react/react-in-jsx-scope": 0, + "no-console": 2, }; const languageOptions = { parser: tsParser, ecmaVersion: 9, sourceType: "module", + parserOptions: { + project: path.join(fileURLToPath(import.meta.url), "..", "tsconfig.all.json"), + } }; export default [ diff --git a/examples/generate-test.md b/examples/generate-test.md new file mode 100644 index 0000000..b6d8dd8 --- /dev/null +++ b/examples/generate-test.md @@ -0,0 +1,10 @@ +Use Playwright tools to generate test for scenario: + +## GitHub PR Checks Navigation Checklist + +1. Open the [Microsoft Playwright GitHub repository](https://github.com/microsoft/playwright). +2. Click on the **Pull requests** tab. +3. Find and open the pull request titled **"chore: make noWaitAfter a default"**. +4. Switch to the **Checks** tab for that pull request. +5. Expand the **infra** check suite to view its jobs. +6. Click on the **docs & lint** job to view its details. diff --git a/index.d.ts b/index.d.ts index c315dfe..551ed55 100644 --- a/index.d.ts +++ b/index.d.ts @@ -15,26 +15,16 @@ * limitations under the License. */ -import type { LaunchOptions } from 'playwright'; import type { Server } from '@modelcontextprotocol/sdk/server/index.js'; +import type { Config } from './config'; +import type { Transport } from '@modelcontextprotocol/sdk/shared/transport.js'; +import type { BrowserContext } from 'playwright'; -type Options = { - /** - * Path to the user data directory. - */ - userDataDir?: string; - - /** - * Launch options for the browser. - */ - launchOptions?: LaunchOptions; - - /** - * Use screenshots instead of snapshots. Less accurate, reliable and overall - * slower, but contains visual representation of the page. - * @default false - */ - vision?: boolean; +export type Connection = { + server: Server; + connect(transport: Transport): Promise; + close(): Promise; }; -export function createServer(options?: Options): Server; +export declare function createConnection(config?: Config, contextGetter?: () => Promise): Promise; +export {}; diff --git a/index.js b/index.js index faf60b5..69f7ae2 100755 --- a/index.js +++ b/index.js @@ -15,5 +15,5 @@ * limitations under the License. */ -const { createServer } = require('./lib/index'); -module.exports = { createServer }; +import { createConnection } from './lib/index.js'; +export { createConnection }; diff --git a/package-lock.json b/package-lock.json index 708cc91..c556e4f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,17 +1,19 @@ { "name": "@playwright/mcp", - "version": "0.0.7", + "version": "0.0.28", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@playwright/mcp", - "version": "0.0.7", + "version": "0.0.28", "license": "Apache-2.0", "dependencies": { - "@modelcontextprotocol/sdk": "^1.6.1", + "@modelcontextprotocol/sdk": "^1.11.0", "commander": "^13.1.0", - "playwright": "1.52.0-alpha-1743011787000", + "debug": "^4.4.1", + "mime": "^4.0.7", + "playwright": "1.53.0-alpha-2025-05-27", "zod-to-json-schema": "^3.24.4" }, "bin": { @@ -20,8 +22,9 @@ "devDependencies": { "@eslint/eslintrc": "^3.2.0", "@eslint/js": "^9.19.0", - "@playwright/test": "1.52.0-alpha-1743011787000", + "@playwright/test": "1.53.0-alpha-2025-05-27", "@stylistic/eslint-plugin": "^3.0.1", + "@types/debug": "^4.1.12", "@types/node": "^22.13.10", "@typescript-eslint/eslint-plugin": "^8.26.1", "@typescript-eslint/parser": "^8.26.1", @@ -227,17 +230,18 @@ } }, "node_modules/@modelcontextprotocol/sdk": { - "version": "1.7.0", - "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.7.0.tgz", - "integrity": "sha512-IYPe/FLpvF3IZrd/f5p5ffmWhMc3aEMuM2wGJASDqC2Ge7qatVCdbfPx3n/5xFeb19xN0j/911M2AaFuircsWA==", + "version": "1.11.0", + "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.11.0.tgz", + "integrity": "sha512-k/1pb70eD638anoi0e8wUGAlbMJXyvdV4p62Ko+EZ7eBe1xMx8Uhak1R5DgfoofsK5IBBnRwsYGTaLZl+6/+RQ==", "license": "MIT", "dependencies": { "content-type": "^1.0.5", "cors": "^2.8.5", + "cross-spawn": "^7.0.3", "eventsource": "^3.0.2", "express": "^5.0.1", "express-rate-limit": "^7.5.0", - "pkce-challenge": "^4.1.0", + "pkce-challenge": "^5.0.0", "raw-body": "^3.0.0", "zod": "^3.23.8", "zod-to-json-schema": "^3.24.1" @@ -285,13 +289,13 @@ } }, "node_modules/@playwright/test": { - "version": "1.52.0-alpha-1743011787000", - "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.52.0-alpha-1743011787000.tgz", - "integrity": "sha512-ikJR8JXof5IBvErrmIsR3ixov4nKlQe/6PSYK/R6eTEe6eoT+eEXlaNY4z6mn9dF02Z1zYGxzAbb8TvSvuwh4Q==", + "version": "1.53.0-alpha-2025-05-27", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.53.0-alpha-2025-05-27.tgz", + "integrity": "sha512-G2zG56kEQOWhk3nQyPKH5u41jyQw5jx+Kga5huUi7RjBjPEnNtiCMNXMNGCh6dDYCIyQkLJvz/o1H/QN26HLsg==", "dev": true, "license": "Apache-2.0", "dependencies": { - "playwright": "1.52.0-alpha-1743011787000" + "playwright": "1.53.0-alpha-2025-05-27" }, "bin": { "playwright": "cli.js" @@ -353,6 +357,16 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/@types/debug": { + "version": "4.1.12", + "resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.12.tgz", + "integrity": "sha512-vIChWdVG3LG1SMxEvI/AK+FWJthlrqlTu7fbrlywTkkaONwk/UAGaULXRlf8vkzFBLVm0zkMdCquhL5aOjhXPQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/ms": "*" + } + }, "node_modules/@types/estree": { "version": "1.0.6", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.6.tgz", @@ -374,8 +388,17 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/ms": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz", + "integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/node": { "version": "22.13.10", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.13.10.tgz", + "integrity": "sha512-I6LPUvlRH+O6VRUqYOcMudhaIdUVWfsjnZavnsraHvpBwaEyMN29ry+0UVJhImYL16xsscu0aske3yA+uPOWfw==", "dev": true, "license": "MIT", "dependencies": { @@ -831,16 +854,16 @@ "license": "MIT" }, "node_modules/body-parser": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.1.0.tgz", - "integrity": "sha512-/hPxh61E+ll0Ujp24Ilm64cykicul1ypfwjVttduAiEdtnJFvLePSrIPk+HMImtNv5270wOGCb1Tns2rybMkoQ==", + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.0.tgz", + "integrity": "sha512-02qvAaxv8tp7fBa/mw1ga98OGm+eCbqzJOKoRt70sLmfEEi+jyBYVTDGfCL/k06/4EMk/z01gCe7HoCH/f2LTg==", "license": "MIT", "dependencies": { "bytes": "^3.1.2", "content-type": "^1.0.5", "debug": "^4.4.0", "http-errors": "^2.0.0", - "iconv-lite": "^0.5.2", + "iconv-lite": "^0.6.3", "on-finished": "^2.4.1", "qs": "^6.14.0", "raw-body": "^3.0.0", @@ -850,44 +873,6 @@ "node": ">=18" } }, - "node_modules/body-parser/node_modules/debug": { - "version": "4.4.0", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.0.tgz", - "integrity": "sha512-6WTZ/IxCY/T6BALoZHaE4ctp9xm+Z5kY/pzYaCHRFeyVhojxlrm+46y68HA6hr0TcwEssoxNiDEUJQjfPZ/RYA==", - "license": "MIT", - "dependencies": { - "ms": "^2.1.3" - }, - "engines": { - "node": ">=6.0" - }, - "peerDependenciesMeta": { - "supports-color": { - "optional": true - } - } - }, - "node_modules/body-parser/node_modules/ms": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", - "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "license": "MIT" - }, - "node_modules/body-parser/node_modules/qs": { - "version": "6.14.0", - "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.0.tgz", - "integrity": "sha512-YWWTjgABSKcvs/nWBi9PycY/JiPJqOD4JA6o9Sej2AtvSGarXxKC3OQSk4pAarbdQlKAh5D4FCQkJNkW+GAn3w==", - "license": "BSD-3-Clause", - "dependencies": { - "side-channel": "^1.1.0" - }, - "engines": { - "node": ">=0.6" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/brace-expansion": { "version": "1.1.11", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", @@ -1018,6 +1003,8 @@ }, "node_modules/commander": { "version": "13.1.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-13.1.0.tgz", + "integrity": "sha512-/rFeCpNJQbhSZjGVwO9RFV3xPqbnERS8MmIQzCtD/zl6gpJuV/bMLuN92oG3F7d8oDEHHRrujSXNUr8fpjntKw==", "license": "MIT", "engines": { "node": ">=18" @@ -1086,7 +1073,6 @@ "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", - "dev": true, "license": "MIT", "dependencies": { "path-key": "^3.1.0", @@ -1152,12 +1138,12 @@ } }, "node_modules/debug": { - "version": "4.3.6", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.6.tgz", - "integrity": "sha512-O/09Bd4Z1fBrU4VzkhFqVgpPzaGbw6Sm9FEkBT1A/YBXQFGuuSxa1dN2nxgxS34JmKXqYx8CZAwEVoJFImUXIg==", + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.1.tgz", + "integrity": "sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ==", "license": "MIT", "dependencies": { - "ms": "2.1.2" + "ms": "^2.1.3" }, "engines": { "node": ">=6.0" @@ -1220,16 +1206,6 @@ "node": ">= 0.8" } }, - "node_modules/destroy": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz", - "integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==", - "license": "MIT", - "engines": { - "node": ">= 0.8", - "npm": "1.2.8000 || >= 1.4.16" - } - }, "node_modules/doctrine": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-2.1.0.tgz", @@ -1765,46 +1741,45 @@ } }, "node_modules/express": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/express/-/express-5.0.1.tgz", - "integrity": "sha512-ORF7g6qGnD+YtUG9yx4DFoqCShNMmUKiXuT5oWMHiOvt/4WFbHC6yCwQMTSBMno7AqntNCAzzcnnjowRkTL9eQ==", + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/express/-/express-5.1.0.tgz", + "integrity": "sha512-DT9ck5YIRU+8GYzzU5kT3eHGA5iL+1Zd0EutOmTE9Dtk+Tvuzd23VBU+ec7HPNSTxXYO55gPV/hq4pSBJDjFpA==", "license": "MIT", "dependencies": { "accepts": "^2.0.0", - "body-parser": "^2.0.1", + "body-parser": "^2.2.0", "content-disposition": "^1.0.0", - "content-type": "~1.0.4", - "cookie": "0.7.1", + "content-type": "^1.0.5", + "cookie": "^0.7.1", "cookie-signature": "^1.2.1", - "debug": "4.3.6", - "depd": "2.0.0", - "encodeurl": "~2.0.0", - "escape-html": "~1.0.3", - "etag": "~1.8.1", - "finalhandler": "^2.0.0", - "fresh": "2.0.0", - "http-errors": "2.0.0", + "debug": "^4.4.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "finalhandler": "^2.1.0", + "fresh": "^2.0.0", + "http-errors": "^2.0.0", "merge-descriptors": "^2.0.0", - "methods": "~1.1.2", "mime-types": "^3.0.0", - "on-finished": "2.4.1", - "once": "1.4.0", - "parseurl": "~1.3.3", - "proxy-addr": "~2.0.7", - "qs": "6.13.0", - "range-parser": "~1.2.1", - "router": "^2.0.0", - "safe-buffer": "5.2.1", + "on-finished": "^2.4.1", + "once": "^1.4.0", + "parseurl": "^1.3.3", + "proxy-addr": "^2.0.7", + "qs": "^6.14.0", + "range-parser": "^1.2.1", + "router": "^2.2.0", "send": "^1.1.0", - "serve-static": "^2.1.0", - "setprototypeof": "1.2.0", - "statuses": "2.0.1", - "type-is": "^2.0.0", - "utils-merge": "1.0.1", - "vary": "~1.1.2" + "serve-static": "^2.2.0", + "statuses": "^2.0.1", + "type-is": "^2.0.1", + "vary": "^1.1.2" }, "engines": { "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" } }, "node_modules/express-rate-limit": { @@ -1926,29 +1901,6 @@ "node": ">= 0.8" } }, - "node_modules/finalhandler/node_modules/debug": { - "version": "4.4.0", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.0.tgz", - "integrity": "sha512-6WTZ/IxCY/T6BALoZHaE4ctp9xm+Z5kY/pzYaCHRFeyVhojxlrm+46y68HA6hr0TcwEssoxNiDEUJQjfPZ/RYA==", - "license": "MIT", - "dependencies": { - "ms": "^2.1.3" - }, - "engines": { - "node": ">=6.0" - }, - "peerDependenciesMeta": { - "supports-color": { - "optional": true - } - } - }, - "node_modules/finalhandler/node_modules/ms": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", - "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "license": "MIT" - }, "node_modules/find-root": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/find-root/-/find-root-1.1.0.tgz", @@ -2308,12 +2260,12 @@ } }, "node_modules/iconv-lite": { - "version": "0.5.2", - "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.5.2.tgz", - "integrity": "sha512-kERHXvpSaB4aU3eANwidg79K8FlrN77m8G9V+0vOR3HYaRifrlwMEpT7ZBJqLSEIHnEgJTHcWK82wwLwwKwtag==", + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", + "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", "license": "MIT", "dependencies": { - "safer-buffer": ">= 2.1.2 < 3" + "safer-buffer": ">= 2.1.2 < 3.0.0" }, "engines": { "node": ">=0.10.0" @@ -2781,7 +2733,6 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", - "dev": true, "license": "ISC" }, "node_modules/js-yaml": { @@ -2925,15 +2876,6 @@ "node": ">= 8" } }, - "node_modules/methods": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz", - "integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==", - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, "node_modules/metric-lcs": { "version": "0.1.2", "resolved": "https://registry.npmjs.org/metric-lcs/-/metric-lcs-0.1.2.tgz", @@ -2955,6 +2897,21 @@ "node": ">=8.6" } }, + "node_modules/mime": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/mime/-/mime-4.0.7.tgz", + "integrity": "sha512-2OfDPL+e03E0LrXaGYOtTFIYhiuzep94NSsuhrNULq+stylcJedcHdzHtz0atMUuGwJfFYs0YL5xeC/Ca2x0eQ==", + "funding": [ + "https://github.com/sponsors/broofa" + ], + "license": "MIT", + "bin": { + "mime": "bin/cli.js" + }, + "engines": { + "node": ">=16" + } + }, "node_modules/mime-db": { "version": "1.54.0", "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", @@ -2965,12 +2922,12 @@ } }, "node_modules/mime-types": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.0.tgz", - "integrity": "sha512-XqoSHeCGjVClAmoGFG3lVFqQFRIrTVw2OH3axRqAcfaw+gHWIfnASS92AV+Rl/mk0MupgZTRHQOjxY6YVnzK5w==", + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.1.tgz", + "integrity": "sha512-xRc4oEhT6eaBpU1XF7AjpOFD+xQmXNB5OVKwp4tqCuBpHLS/ZbBDrc07mYTDqVMg6PfxUjjNp85O6Cd2Z/5HWA==", "license": "MIT", "dependencies": { - "mime-db": "^1.53.0" + "mime-db": "^1.54.0" }, "engines": { "node": ">= 0.6" @@ -3000,9 +2957,9 @@ } }, "node_modules/ms": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", - "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==", + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", "license": "MIT" }, "node_modules/natural-compare": { @@ -3251,7 +3208,6 @@ "version": "3.1.1", "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", - "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -3287,21 +3243,21 @@ } }, "node_modules/pkce-challenge": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/pkce-challenge/-/pkce-challenge-4.1.0.tgz", - "integrity": "sha512-ZBmhE1C9LcPoH9XZSdwiPtbPHZROwAnMy+kIFQVrnMCxY4Cudlz3gBOpzilgc0jOgRaiT3sIWfpMomW2ar2orQ==", + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/pkce-challenge/-/pkce-challenge-5.0.0.tgz", + "integrity": "sha512-ueGLflrrnvwB3xuo/uGob5pd5FN7l0MsLf0Z87o/UQmRtwjvfylfc9MurIxRAWywCYTgrvpXBcqjV4OfCYGCIQ==", "license": "MIT", "engines": { "node": ">=16.20.0" } }, "node_modules/playwright": { - "version": "1.52.0-alpha-1743011787000", - "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.52.0-alpha-1743011787000.tgz", - "integrity": "sha512-wg9Tu4ZDKJWo7hBKpeuD/XLtLOQ7fCCuBfekgUrPLStA12O3224E1fbp/xGFnmi47SF71Y8F6C2Beyd3gYFWlQ==", + "version": "1.53.0-alpha-2025-05-27", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.53.0-alpha-2025-05-27.tgz", + "integrity": "sha512-CD0BTwV5javEJ3hf3rhFJEvR3ZoWsu4HUQFfLH2mtVVe+grGPCP55FnlOjpDnJ5pP4Kibe/ZcmgPDg56ic/y9g==", "license": "Apache-2.0", "dependencies": { - "playwright-core": "1.52.0-alpha-1743011787000" + "playwright-core": "1.53.0-alpha-2025-05-27" }, "bin": { "playwright": "cli.js" @@ -3314,9 +3270,9 @@ } }, "node_modules/playwright-core": { - "version": "1.52.0-alpha-1743011787000", - "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.52.0-alpha-1743011787000.tgz", - "integrity": "sha512-yOpMfKxTBRqdm50b52cojvTCNttWN+Xk6LXF+KU4ufcGwcRjUud1xdHmHHvQNFFanXM1MBYnDKsMkRvjPsuYOw==", + "version": "1.53.0-alpha-2025-05-27", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.53.0-alpha-2025-05-27.tgz", + "integrity": "sha512-uVxs7YjENoBMFyQhsZWImIBuo/oX7Mu63djhQN3qFz/NdXA/rOAnP73XzfB+VJNwRMKgIOtqHQgjOG3Rl/lm0A==", "license": "Apache-2.0", "bin": { "playwright-core": "cli.js" @@ -3369,12 +3325,12 @@ } }, "node_modules/qs": { - "version": "6.13.0", - "resolved": "https://registry.npmjs.org/qs/-/qs-6.13.0.tgz", - "integrity": "sha512-+38qI9SOr8tfZ4QmJNplMUxqjbe7LKvvZgWdExBOmd+egZTtjLB67Gu0HRX3u/XOq7UU2Nx6nsjvS16Z9uwfpg==", + "version": "6.14.0", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.0.tgz", + "integrity": "sha512-YWWTjgABSKcvs/nWBi9PycY/JiPJqOD4JA6o9Sej2AtvSGarXxKC3OQSk4pAarbdQlKAh5D4FCQkJNkW+GAn3w==", "license": "BSD-3-Clause", "dependencies": { - "side-channel": "^1.0.6" + "side-channel": "^1.1.0" }, "engines": { "node": ">=0.6" @@ -3428,18 +3384,6 @@ "node": ">= 0.8" } }, - "node_modules/raw-body/node_modules/iconv-lite": { - "version": "0.6.3", - "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", - "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", - "license": "MIT", - "dependencies": { - "safer-buffer": ">= 2.1.2 < 3.0.0" - }, - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/reflect.getprototypeof": { "version": "1.0.10", "resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz", @@ -3527,11 +3471,13 @@ } }, "node_modules/router": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/router/-/router-2.1.0.tgz", - "integrity": "sha512-/m/NSLxeYEgWNtyC+WtNHCF7jbGxOibVWKnn+1Psff4dJGOfoXP+MuC/f2CwSmyiHdOIzYnYFp4W6GxWfekaLA==", + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz", + "integrity": "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==", "license": "MIT", "dependencies": { + "debug": "^4.4.0", + "depd": "^2.0.0", "is-promise": "^4.0.0", "parseurl": "^1.3.3", "path-to-regexp": "^8.0.0" @@ -3659,19 +3605,18 @@ } }, "node_modules/send": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/send/-/send-1.1.0.tgz", - "integrity": "sha512-v67WcEouB5GxbTWL/4NeToqcZiAWEq90N888fczVArY8A79J0L4FD7vj5hm3eUMua5EpoQ59wa/oovY6TLvRUA==", + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/send/-/send-1.2.0.tgz", + "integrity": "sha512-uaW0WwXKpL9blXE2o0bRhoL2EGXIrZxQ2ZQ4mgcfoBxdFmQold+qWsD2jLrfZ0trjKL6vOw0j//eAwcALFjKSw==", "license": "MIT", "dependencies": { "debug": "^4.3.5", - "destroy": "^1.2.0", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "etag": "^1.8.1", - "fresh": "^0.5.2", + "fresh": "^2.0.0", "http-errors": "^2.0.0", - "mime-types": "^2.1.35", + "mime-types": "^3.0.1", "ms": "^2.1.3", "on-finished": "^2.4.1", "range-parser": "^1.2.1", @@ -3681,52 +3626,16 @@ "node": ">= 18" } }, - "node_modules/send/node_modules/fresh": { - "version": "0.5.2", - "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz", - "integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==", - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/send/node_modules/mime-db": { - "version": "1.52.0", - "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", - "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/send/node_modules/mime-types": { - "version": "2.1.35", - "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", - "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", - "license": "MIT", - "dependencies": { - "mime-db": "1.52.0" - }, - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/send/node_modules/ms": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", - "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "license": "MIT" - }, "node_modules/serve-static": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-2.1.0.tgz", - "integrity": "sha512-A3We5UfEjG8Z7VkDv6uItWw6HY2bBSBJT1KtVESn6EOoOr2jAxNhxWCLY3jDE2WcuHXByWju74ck3ZgLwL8xmA==", + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-2.2.0.tgz", + "integrity": "sha512-61g9pCh0Vnh7IutZjtLGGpTA355+OPn2TyDv/6ivP2h/AdAVX9azsoxmg2/M6nZeQZNYBEwIcsne1mJd9oQItQ==", "license": "MIT", "dependencies": { "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "parseurl": "^1.3.3", - "send": "^1.0.0" + "send": "^1.2.0" }, "engines": { "node": ">= 18" @@ -3791,7 +3700,6 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", - "dev": true, "license": "MIT", "dependencies": { "shebang-regex": "^3.0.0" @@ -3804,7 +3712,6 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", - "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -4061,9 +3968,9 @@ } }, "node_modules/type-is": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.0.0.tgz", - "integrity": "sha512-gd0sGezQYCbWSbkZr75mln4YBidWUN60+devscpLF5mtRDUpiaTvKpBNrdaCvel1NdR2k6vclXybU5fBd2i+nw==", + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.0.1.tgz", + "integrity": "sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw==", "license": "MIT", "dependencies": { "content-type": "^1.0.5", @@ -4187,6 +4094,8 @@ }, "node_modules/undici-types": { "version": "6.20.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.20.0.tgz", + "integrity": "sha512-Ny6QZ2Nju20vw1SRHe3d9jVu6gJ+4e3+MMpqu7pqE5HT6WsTSlce++GQmK5UXS8mzV8DSYHrQH+Xrf2jVcuKNg==", "dev": true, "license": "MIT" }, @@ -4209,15 +4118,6 @@ "punycode": "^2.1.0" } }, - "node_modules/utils-merge": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz", - "integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==", - "license": "MIT", - "engines": { - "node": ">= 0.4.0" - } - }, "node_modules/vary": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", @@ -4231,7 +4131,6 @@ "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", - "dev": true, "license": "ISC", "dependencies": { "isexe": "^2.0.0" @@ -4363,6 +4262,8 @@ }, "node_modules/zod": { "version": "3.24.2", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.24.2.tgz", + "integrity": "sha512-lY7CDW43ECgW9u1TcT3IoXHflywfVqDYze4waEz812jR/bZ8FHDsl7pFQoSZTz5N+2NqRXs8GBwnAwo3ZNxqhQ==", "license": "MIT", "funding": { "url": "https://github.com/sponsors/colinhacks" @@ -4370,6 +4271,8 @@ }, "node_modules/zod-to-json-schema": { "version": "3.24.4", + "resolved": "https://registry.npmjs.org/zod-to-json-schema/-/zod-to-json-schema-3.24.4.tgz", + "integrity": "sha512-0uNlcvgabyrni9Ag8Vghj21drk7+7tp7VTwwR7KxxXXc/3pbXz2PHlDgj3cICahgF1kHm4dExBFj7BXrZJXzig==", "license": "ISC", "peerDependencies": { "zod": "^3.24.1" diff --git a/package.json b/package.json index c0da749..4492291 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,8 @@ { "name": "@playwright/mcp", - "version": "0.0.7", + "version": "0.0.28", "description": "Playwright Tools for MCP", + "type": "module", "repository": { "type": "git", "url": "git+https://github.com/microsoft/playwright-mcp.git" @@ -16,9 +17,13 @@ "license": "Apache-2.0", "scripts": { "build": "tsc", - "lint": "eslint .", + "lint": "npm run update-readme && eslint . && tsc --noEmit", + "update-readme": "node utils/update-readme.js", "watch": "tsc --watch", "test": "playwright test", + "ctest": "playwright test --project=chrome", + "ftest": "playwright test --project=firefox", + "wtest": "playwright test --project=webkit", "clean": "rm -rf lib", "npm-publish": "npm run clean && npm run build && npm run test && npm publish" }, @@ -30,20 +35,23 @@ } }, "dependencies": { - "@modelcontextprotocol/sdk": "^1.6.1", + "@modelcontextprotocol/sdk": "^1.11.0", "commander": "^13.1.0", - "playwright": "1.52.0-alpha-1743011787000", + "debug": "^4.4.1", + "mime": "^4.0.7", + "playwright": "1.53.0-alpha-2025-05-27", "zod-to-json-schema": "^3.24.4" }, "devDependencies": { "@eslint/eslintrc": "^3.2.0", "@eslint/js": "^9.19.0", - "@playwright/test": "1.52.0-alpha-1743011787000", + "@playwright/test": "1.53.0-alpha-2025-05-27", "@stylistic/eslint-plugin": "^3.0.1", + "@types/debug": "^4.1.12", + "@types/node": "^22.13.10", "@typescript-eslint/eslint-plugin": "^8.26.1", "@typescript-eslint/parser": "^8.26.1", "@typescript-eslint/utils": "^8.26.1", - "@types/node": "^22.13.10", "eslint": "^9.19.0", "eslint-plugin-import": "^2.31.0", "eslint-plugin-notice": "^1.0.0", diff --git a/playwright.config.ts b/playwright.config.ts index dfd128d..9c8ba59 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -16,12 +16,28 @@ import { defineConfig } from '@playwright/test'; -export default defineConfig({ +import type { TestOptions } from './tests/fixtures.js'; + +export default defineConfig({ testDir: './tests', fullyParallel: true, forbidOnly: !!process.env.CI, retries: process.env.CI ? 2 : 0, workers: process.env.CI ? 1 : undefined, reporter: 'list', - projects: [{ name: 'default' }], + projects: [ + { name: 'chrome' }, + { name: 'msedge', use: { mcpBrowser: 'msedge' } }, + { name: 'chromium', use: { mcpBrowser: 'chromium' } }, + ...process.env.MCP_IN_DOCKER ? [{ + name: 'chromium-docker', + grep: /browser_navigate|browser_click/, + use: { + mcpBrowser: 'chromium', + mcpMode: 'docker' as const + } + }] : [], + { name: 'firefox', use: { mcpBrowser: 'firefox' } }, + { name: 'webkit', use: { mcpBrowser: 'webkit' } }, + ], }); diff --git a/src/browserAgent.ts b/src/browserAgent.ts new file mode 100644 index 0000000..67ec6f7 --- /dev/null +++ b/src/browserAgent.ts @@ -0,0 +1,197 @@ +/** + * 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. + */ + +/* eslint-disable no-console */ + +import net from 'net'; + +import { program } from 'commander'; +import playwright from 'playwright'; + +import { HttpServer } from './httpServer.js'; +import { packageJSON } from './package.js'; + +import type http from 'http'; + +export type LaunchBrowserRequest = { + browserType: string; + userDataDir: string; + launchOptions: playwright.LaunchOptions; + contextOptions: playwright.BrowserContextOptions; +}; + +export type BrowserInfo = { + browserType: string; + userDataDir: string; + cdpPort: number; + launchOptions: playwright.LaunchOptions; + contextOptions: playwright.BrowserContextOptions; + error?: string; +}; + +type BrowserEntry = { + browser?: playwright.Browser; + info: BrowserInfo; +}; + +class Agent { + private _server = new HttpServer(); + private _entries: BrowserEntry[] = []; + + constructor() { + this._setupExitHandler(); + } + + async start(port: number) { + await this._server.start({ port }); + this._server.routePath('/json/list', (req, res) => { + this._handleJsonList(res); + }); + this._server.routePath('/json/launch', async (req, res) => { + void this._handleLaunchBrowser(req, res).catch(e => console.error(e)); + }); + this._setEntries([]); + } + + private _handleJsonList(res: http.ServerResponse) { + const list = this._entries.map(browser => browser.info); + res.end(JSON.stringify(list)); + } + + private async _handleLaunchBrowser(req: http.IncomingMessage, res: http.ServerResponse) { + const request = await readBody(req); + let info = this._entries.map(entry => entry.info).find(info => info.userDataDir === request.userDataDir); + if (!info || info.error) + info = await this._newBrowser(request); + res.end(JSON.stringify(info)); + } + + private async _newBrowser(request: LaunchBrowserRequest): Promise { + const cdpPort = await findFreePort(); + (request.launchOptions as any).cdpPort = cdpPort; + const info: BrowserInfo = { + browserType: request.browserType, + userDataDir: request.userDataDir, + cdpPort, + launchOptions: request.launchOptions, + contextOptions: request.contextOptions, + }; + + const browserType = playwright[request.browserType as 'chromium' | 'firefox' | 'webkit']; + const { browser, error } = await browserType.launchPersistentContext(request.userDataDir, { + ...request.launchOptions, + ...request.contextOptions, + handleSIGINT: false, + handleSIGTERM: false, + }).then(context => { + return { browser: context.browser()!, error: undefined }; + }).catch(error => { + return { browser: undefined, error: error.message }; + }); + this._setEntries([...this._entries, { + browser, + info: { + browserType: request.browserType, + userDataDir: request.userDataDir, + cdpPort, + launchOptions: request.launchOptions, + contextOptions: request.contextOptions, + error, + }, + }]); + browser?.on('disconnected', () => { + this._setEntries(this._entries.filter(entry => entry.browser !== browser)); + }); + return info; + } + + private _updateReport() { + // Clear the current line and move cursor to top of screen + process.stdout.write('\x1b[2J\x1b[H'); + process.stdout.write(`Playwright Browser agent v${packageJSON.version}\n`); + process.stdout.write(`Listening on ${this._server.urlPrefix('human-readable')}\n\n`); + + if (this._entries.length === 0) { + process.stdout.write('No browsers currently running\n'); + return; + } + + process.stdout.write('Running browsers:\n'); + for (const entry of this._entries) { + const status = entry.browser ? 'running' : 'error'; + const statusColor = entry.browser ? '\x1b[32m' : '\x1b[31m'; // green for running, red for error + process.stdout.write(`${statusColor}${entry.info.browserType}\x1b[0m (${entry.info.userDataDir}) - ${statusColor}${status}\x1b[0m\n`); + if (entry.info.error) + process.stdout.write(` Error: ${entry.info.error}\n`); + } + + } + + private _setEntries(entries: BrowserEntry[]) { + this._entries = entries; + this._updateReport(); + } + + private _setupExitHandler() { + let isExiting = false; + const handleExit = async () => { + if (isExiting) + return; + isExiting = true; + setTimeout(() => process.exit(0), 15000); + for (const entry of this._entries) + await entry.browser?.close().catch(() => {}); + process.exit(0); + }; + + process.stdin.on('close', handleExit); + process.on('SIGINT', handleExit); + process.on('SIGTERM', handleExit); + } +} + +program + .name('browser-agent') + .option('-p, --port ', 'Port to listen on', '9224') + .action(async options => { + await main(options); + }); + +void program.parseAsync(process.argv); + +async function main(options: { port: string }) { + const agent = new Agent(); + await agent.start(+options.port); +} + +function readBody(req: http.IncomingMessage): Promise { + return new Promise((resolve, reject) => { + const chunks: Buffer[] = []; + req.on('data', (chunk: Buffer) => chunks.push(chunk)); + req.on('end', () => resolve(JSON.parse(Buffer.concat(chunks).toString()))); + }); +} + +async function findFreePort(): Promise { + return new Promise((resolve, reject) => { + const server = net.createServer(); + server.listen(0, () => { + const { port } = server.address() as net.AddressInfo; + server.close(() => resolve(port)); + }); + server.on('error', reject); + }); +} diff --git a/src/browserContextFactory.ts b/src/browserContextFactory.ts new file mode 100644 index 0000000..92fbf18 --- /dev/null +++ b/src/browserContextFactory.ts @@ -0,0 +1,266 @@ +/** + * 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 'node:fs'; +import net from 'node:net'; +import path from 'node:path'; +import os from 'node:os'; + +import debug from 'debug'; +import * as playwright from 'playwright'; +import { userDataDir } from './fileUtils.js'; + +import type { FullConfig } from './config.js'; +import type { BrowserInfo, LaunchBrowserRequest } from './browserAgent.js'; + +const testDebug = debug('pw:mcp:test'); + +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); + if (browserConfig.browserAgent) + return new AgentContextFactory(browserConfig); + return new PersistentContextFactory(browserConfig); +} + +export interface BrowserContextFactory { + createContext(): Promise<{ browserContext: playwright.BrowserContext, close: () => Promise }>; +} + +class BaseContextFactory implements BrowserContextFactory { + readonly browserConfig: FullConfig['browser']; + protected _browserPromise: Promise | undefined; + readonly name: string; + + constructor(name: string, browserConfig: FullConfig['browser']) { + this.name = name; + this.browserConfig = browserConfig; + } + + protected async _obtainBrowser(): Promise { + if (this._browserPromise) + return this._browserPromise; + testDebug(`obtain browser (${this.name})`); + this._browserPromise = this._doObtainBrowser(); + void this._browserPromise.then(browser => { + browser.on('disconnected', () => { + this._browserPromise = undefined; + }); + }).catch(() => { + this._browserPromise = undefined; + }); + return this._browserPromise; + } + + protected async _doObtainBrowser(): Promise { + throw new Error('Not implemented'); + } + + async createContext(): Promise<{ browserContext: playwright.BrowserContext, close: () => Promise }> { + testDebug(`create browser context (${this.name})`); + const browser = await this._obtainBrowser(); + const browserContext = await this._doCreateContext(browser); + return { browserContext, close: () => this._closeBrowserContext(browserContext, browser) }; + } + + protected async _doCreateContext(browser: playwright.Browser): Promise { + throw new Error('Not implemented'); + } + + private async _closeBrowserContext(browserContext: playwright.BrowserContext, browser: playwright.Browser) { + testDebug(`close browser context (${this.name})`); + if (browser.contexts().length === 1) + this._browserPromise = undefined; + await browserContext.close().catch(() => {}); + if (browser.contexts().length === 0) { + testDebug(`close browser (${this.name})`); + await browser.close().catch(() => {}); + } + } +} + +class IsolatedContextFactory extends BaseContextFactory { + constructor(browserConfig: FullConfig['browser']) { + super('isolated', browserConfig); + } + + protected override async _doObtainBrowser(): Promise { + await injectCdpPort(this.browserConfig); + const browserType = playwright[this.browserConfig.browserName]; + return browserType.launch({ + ...this.browserConfig.launchOptions, + handleSIGINT: false, + handleSIGTERM: false, + }).catch(error => { + if (error.message.includes('Executable doesn\'t exist')) + throw new Error(`Browser specified in your config is not installed. Either install it (likely) or change the config.`); + throw error; + }); + } + + protected override async _doCreateContext(browser: playwright.Browser): Promise { + return browser.newContext(this.browserConfig.contextOptions); + } +} + +class CdpContextFactory extends BaseContextFactory { + constructor(browserConfig: FullConfig['browser']) { + super('cdp', browserConfig); + } + + protected override async _doObtainBrowser(): Promise { + return playwright.chromium.connectOverCDP(this.browserConfig.cdpEndpoint!); + } + + protected override async _doCreateContext(browser: playwright.Browser): Promise { + return this.browserConfig.isolated ? await browser.newContext() : browser.contexts()[0]; + } +} + +class RemoteContextFactory extends BaseContextFactory { + constructor(browserConfig: FullConfig['browser']) { + super('remote', browserConfig); + } + + protected override async _doObtainBrowser(): Promise { + 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)); + } + + protected override async _doCreateContext(browser: playwright.Browser): Promise { + return browser.newContext(); + } +} + +class PersistentContextFactory implements BrowserContextFactory { + readonly browserConfig: FullConfig['browser']; + private _userDataDirs = new Set(); + + constructor(browserConfig: FullConfig['browser']) { + this.browserConfig = browserConfig; + } + + async createContext(): Promise<{ browserContext: playwright.BrowserContext, close: () => Promise }> { + await injectCdpPort(this.browserConfig); + testDebug('create browser context (persistent)'); + const userDataDir = this.browserConfig.userDataDir ?? await this._createUserDataDir(); + + this._userDataDirs.add(userDataDir); + testDebug('lock user data dir', userDataDir); + + const browserType = playwright[this.browserConfig.browserName]; + for (let i = 0; i < 5; i++) { + try { + const browserContext = await browserType.launchPersistentContext(userDataDir, { + ...this.browserConfig.launchOptions, + ...this.browserConfig.contextOptions, + handleSIGINT: false, + handleSIGTERM: false, + }); + const close = () => this._closeBrowserContext(browserContext, userDataDir); + return { browserContext, close }; + } catch (error: any) { + if (error.message.includes('Executable doesn\'t exist')) + throw new Error(`Browser specified in your config is not installed. Either install it (likely) or change the config.`); + if (error.message.includes('ProcessSingleton') || error.message.includes('Invalid URL')) { + // User data directory is already in use, try again. + await new Promise(resolve => setTimeout(resolve, 1000)); + continue; + } + throw error; + } + } + throw new Error(`Browser is already in use for ${userDataDir}, use --isolated to run multiple instances of the same browser`); + } + + private async _closeBrowserContext(browserContext: playwright.BrowserContext, userDataDir: string) { + testDebug('close browser context (persistent)'); + testDebug('release user data dir', userDataDir); + await browserContext.close().catch(() => {}); + this._userDataDirs.delete(userDataDir); + 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`); + await fs.promises.mkdir(result, { recursive: true }); + return result; + } +} + +export class AgentContextFactory extends BaseContextFactory { + constructor(browserConfig: FullConfig['browser']) { + super('persistent', browserConfig); + } + + protected override async _doObtainBrowser(): Promise { + const response = await fetch(new URL(`/json/launch`, this.browserConfig.browserAgent), { + method: 'POST', + body: JSON.stringify({ + browserType: this.browserConfig.browserName, + userDataDir: this.browserConfig.userDataDir ?? await this._createUserDataDir(), + launchOptions: this.browserConfig.launchOptions, + contextOptions: this.browserConfig.contextOptions, + } as LaunchBrowserRequest), + }); + const info = await response.json() as BrowserInfo; + if (info.error) + throw new Error(info.error); + return await playwright.chromium.connectOverCDP(`http://localhost:${info.cdpPort}/`); + } + + protected override async _doCreateContext(browser: playwright.Browser): Promise { + return this.browserConfig.isolated ? await browser.newContext() : browser.contexts()[0]; + } + + private async _createUserDataDir() { + const dir = await userDataDir(this.browserConfig); + await fs.promises.mkdir(dir, { recursive: true }); + return dir; + } +} + +async function injectCdpPort(browserConfig: FullConfig['browser']) { + if (browserConfig.browserName === 'chromium') + (browserConfig.launchOptions as any).cdpPort = await findFreePort(); +} + +async function findFreePort() { + return new Promise((resolve, reject) => { + const server = net.createServer(); + server.listen(0, () => { + const { port } = server.address() as net.AddressInfo; + server.close(() => resolve(port)); + }); + server.on('error', reject); + }); +} diff --git a/src/config.ts b/src/config.ts new file mode 100644 index 0000000..f25e5a2 --- /dev/null +++ b/src/config.ts @@ -0,0 +1,254 @@ +/** + * 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 os from 'os'; +import path from 'path'; +import { devices } from 'playwright'; + +import type { Config, ToolCapability } from '../config.js'; +import type { BrowserContextOptions, LaunchOptions } from 'playwright'; +import { sanitizeForFilePath } from './tools/utils.js'; + +export type CLIOptions = { + allowedOrigins?: string[]; + blockedOrigins?: string[]; + blockServiceWorkers?: boolean; + browser?: string; + browserAgent?: string; + caps?: string; + cdpEndpoint?: string; + config?: string; + device?: string; + executablePath?: string; + headless?: boolean; + host?: string; + ignoreHttpsErrors?: boolean; + isolated?: boolean; + imageResponses?: 'allow' | 'omit' | 'auto'; + sandbox: boolean; + outputDir?: string; + port?: number; + proxyBypass?: string; + proxyServer?: string; + saveTrace?: boolean; + storageState?: string; + userAgent?: string; + userDataDir?: string; + viewportSize?: string; + vision?: boolean; +}; + +const defaultConfig: FullConfig = { + browser: { + browserName: 'chromium', + launchOptions: { + channel: 'chrome', + headless: os.platform() === 'linux' && !process.env.DISPLAY, + chromiumSandbox: true, + }, + contextOptions: { + viewport: null, + }, + }, + network: { + allowedOrigins: undefined, + blockedOrigins: undefined, + }, + server: {}, + outputDir: path.join(os.tmpdir(), 'playwright-mcp-output', sanitizeForFilePath(new Date().toISOString())), +}; + +type BrowserUserConfig = NonNullable; + +export type FullConfig = Config & { + browser: Omit & { + browserName: 'chromium' | 'firefox' | 'webkit'; + launchOptions: NonNullable; + contextOptions: NonNullable; + }, + network: NonNullable, + outputDir: string; + server: NonNullable, +}; + +export async function resolveConfig(config: Config): Promise { + return mergeConfig(defaultConfig, config); +} + +export async function resolveCLIConfig(cliOptions: CLIOptions): Promise { + const configInFile = await loadConfig(cliOptions.config); + const cliOverrides = await configFromCLIOptions(cliOptions); + const result = mergeConfig(mergeConfig(defaultConfig, configInFile), cliOverrides); + // Derive artifact output directory from config.outputDir + if (result.saveTrace) + result.browser.launchOptions.tracesDir = path.join(result.outputDir, 'traces'); + return result; +} + +export async function configFromCLIOptions(cliOptions: CLIOptions): Promise { + let browserName: 'chromium' | 'firefox' | 'webkit' | undefined; + let channel: string | undefined; + switch (cliOptions.browser) { + case 'chrome': + case 'chrome-beta': + case 'chrome-canary': + case 'chrome-dev': + case 'chromium': + case 'msedge': + case 'msedge-beta': + case 'msedge-canary': + case 'msedge-dev': + browserName = 'chromium'; + channel = cliOptions.browser; + break; + case 'firefox': + browserName = 'firefox'; + break; + case 'webkit': + browserName = 'webkit'; + break; + } + + // Launch options + const launchOptions: LaunchOptions = { + channel, + executablePath: cliOptions.executablePath, + headless: cliOptions.headless, + }; + + // --no-sandbox was passed, disable the sandbox + if (!cliOptions.sandbox) + launchOptions.chromiumSandbox = false; + + if (cliOptions.proxyServer) { + launchOptions.proxy = { + server: cliOptions.proxyServer + }; + if (cliOptions.proxyBypass) + launchOptions.proxy.bypass = cliOptions.proxyBypass; + } + + // Context options + const contextOptions: BrowserContextOptions = cliOptions.device ? devices[cliOptions.device] : {}; + if (cliOptions.storageState) + contextOptions.storageState = cliOptions.storageState; + + if (cliOptions.userAgent) + contextOptions.userAgent = cliOptions.userAgent; + + if (cliOptions.viewportSize) { + try { + const [width, height] = cliOptions.viewportSize.split(',').map(n => +n); + if (isNaN(width) || isNaN(height)) + throw new Error('bad values'); + contextOptions.viewport = { width, height }; + } catch (e) { + throw new Error('Invalid viewport size format: use "width,height", for example --viewport-size="800,600"'); + } + } + + if (cliOptions.ignoreHttpsErrors) + contextOptions.ignoreHTTPSErrors = true; + + if (cliOptions.blockServiceWorkers) + contextOptions.serviceWorkers = 'block'; + + const result: Config = { + browser: { + browserAgent: cliOptions.browserAgent ?? process.env.PW_BROWSER_AGENT, + browserName, + isolated: cliOptions.isolated, + userDataDir: cliOptions.userDataDir, + launchOptions, + contextOptions, + cdpEndpoint: cliOptions.cdpEndpoint, + }, + server: { + port: cliOptions.port, + host: cliOptions.host, + }, + capabilities: cliOptions.caps?.split(',').map((c: string) => c.trim() as ToolCapability), + vision: !!cliOptions.vision, + network: { + allowedOrigins: cliOptions.allowedOrigins, + blockedOrigins: cliOptions.blockedOrigins, + }, + saveTrace: cliOptions.saveTrace, + outputDir: cliOptions.outputDir, + imageResponses: cliOptions.imageResponses, + }; + + return result; +} + +async function loadConfig(configFile: string | undefined): Promise { + if (!configFile) + return {}; + + try { + return JSON.parse(await fs.promises.readFile(configFile, 'utf8')); + } catch (error) { + throw new Error(`Failed to load config file: ${configFile}, ${error}`); + } +} + +export async function outputFile(config: FullConfig, name: string): Promise { + await fs.promises.mkdir(config.outputDir, { recursive: true }); + const fileName = sanitizeForFilePath(name); + return path.join(config.outputDir, fileName); +} + +function pickDefined(obj: T | undefined): Partial { + return Object.fromEntries( + Object.entries(obj ?? {}).filter(([_, v]) => v !== undefined) + ) as Partial; +} + +function mergeConfig(base: FullConfig, overrides: Config): FullConfig { + const browser: FullConfig['browser'] = { + ...pickDefined(base.browser), + ...pickDefined(overrides.browser), + browserName: overrides.browser?.browserName ?? base.browser?.browserName ?? 'chromium', + isolated: overrides.browser?.isolated ?? base.browser?.isolated ?? false, + launchOptions: { + ...pickDefined(base.browser?.launchOptions), + ...pickDefined(overrides.browser?.launchOptions), + ...{ assistantMode: true }, + }, + contextOptions: { + ...pickDefined(base.browser?.contextOptions), + ...pickDefined(overrides.browser?.contextOptions), + }, + }; + + if (browser.browserName !== 'chromium' && browser.launchOptions) + delete browser.launchOptions.channel; + + return { + ...pickDefined(base), + ...pickDefined(overrides), + browser, + network: { + ...pickDefined(base.network), + ...pickDefined(overrides.network), + }, + server: { + ...pickDefined(base.server), + ...pickDefined(overrides.server), + }, + } as FullConfig; +} diff --git a/src/connection.ts b/src/connection.ts new file mode 100644 index 0000000..1c931f8 --- /dev/null +++ b/src/connection.ts @@ -0,0 +1,98 @@ +/** + * 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 { Server as McpServer } from '@modelcontextprotocol/sdk/server/index.js'; +import { CallToolRequestSchema, ListToolsRequestSchema, Tool as McpTool } from '@modelcontextprotocol/sdk/types.js'; +import { zodToJsonSchema } from 'zod-to-json-schema'; + +import { Context } from './context.js'; +import { snapshotTools, visionTools } from './tools.js'; +import { packageJSON } from './package.js'; + +import { FullConfig } from './config.js'; + +import type { BrowserContextFactory } from './browserContextFactory.js'; + +export function createConnection(config: FullConfig, browserContextFactory: BrowserContextFactory): Connection { + const allTools = config.vision ? visionTools : snapshotTools; + const tools = allTools.filter(tool => !config.capabilities || tool.capability === 'core' || config.capabilities.includes(tool.capability)); + + const context = new Context(tools, config, browserContextFactory); + const server = new McpServer({ name: 'Playwright', version: packageJSON.version }, { + capabilities: { + tools: {}, + } + }); + + server.setRequestHandler(ListToolsRequestSchema, async () => { + return { + tools: tools.map(tool => ({ + name: tool.schema.name, + description: tool.schema.description, + inputSchema: zodToJsonSchema(tool.schema.inputSchema), + annotations: { + title: tool.schema.title, + readOnlyHint: tool.schema.type === 'readOnly', + destructiveHint: tool.schema.type === 'destructive', + openWorldHint: true, + }, + })) as McpTool[], + }; + }); + + server.setRequestHandler(CallToolRequestSchema, async request => { + const errorResult = (...messages: string[]) => ({ + content: [{ type: 'text', text: messages.join('\n') }], + isError: true, + }); + const tool = tools.find(tool => tool.schema.name === request.params.name); + if (!tool) + return errorResult(`Tool "${request.params.name}" not found`); + + + const modalStates = context.modalStates().map(state => state.type); + if (tool.clearsModalState && !modalStates.includes(tool.clearsModalState)) + return errorResult(`The tool "${request.params.name}" can only be used when there is related modal state present.`, ...context.modalStatesMarkdown()); + if (!tool.clearsModalState && modalStates.length) + return errorResult(`Tool "${request.params.name}" does not handle the modal state.`, ...context.modalStatesMarkdown()); + + try { + return await context.run(tool, request.params.arguments); + } catch (error) { + return errorResult(String(error)); + } + }); + + return new Connection(server, context); +} + +export class Connection { + readonly server: McpServer; + readonly context: Context; + + constructor(server: McpServer, context: Context) { + this.server = server; + this.context = context; + this.server.oninitialized = () => { + this.context.clientVersion = this.server.getClientVersion(); + }; + } + + async close() { + await this.server.close(); + await this.context.close(); + } +} diff --git a/src/context.ts b/src/context.ts index 943e290..ecf66b9 100644 --- a/src/context.ts +++ b/src/context.ts @@ -14,151 +14,338 @@ * limitations under the License. */ +import debug from 'debug'; import * as playwright from 'playwright'; -export type ContextOptions = { - userDataDir: string; - launchOptions?: playwright.LaunchOptions; - cdpEndpoint?: string; - remoteEndpoint?: string; +import { callOnPageNoTrace, waitForCompletion } from './tools/utils.js'; +import { ManualPromise } from './manualPromise.js'; +import { Tab } from './tab.js'; +import { outputFile } from './config.js'; + +import type { ImageContent, TextContent } from '@modelcontextprotocol/sdk/types.js'; +import type { ModalState, Tool, ToolActionResult } from './tools/tool.js'; +import type { FullConfig } from './config.js'; +import type { BrowserContextFactory } from './browserContextFactory.js'; + +type PendingAction = { + dialogShown: ManualPromise; }; +const testDebug = debug('pw:mcp:test'); + export class Context { - private _options: ContextOptions; - private _browser: playwright.Browser | undefined; - private _page: playwright.Page | undefined; - private _console: playwright.ConsoleMessage[] = []; - private _createPagePromise: Promise | undefined; - private _fileChooser: playwright.FileChooser | undefined; - private _lastSnapshotFrames: playwright.FrameLocator[] = []; - - constructor(options: ContextOptions) { - this._options = options; - } - - async createPage(): Promise { - if (this._createPagePromise) - return this._createPagePromise; - this._createPagePromise = (async () => { - const { browser, page } = await this._createPage(); - page.on('console', event => this._console.push(event)); - page.on('framenavigated', frame => { - if (!frame.parentFrame()) - this._console.length = 0; - }); - page.on('close', () => this._onPageClose()); - page.on('filechooser', chooser => this._fileChooser = chooser); - page.setDefaultNavigationTimeout(60000); - page.setDefaultTimeout(5000); - this._page = page; - this._browser = browser; - return page; - })(); - return this._createPagePromise; + readonly tools: Tool[]; + readonly config: FullConfig; + private _browserContextPromise: Promise<{ browserContext: playwright.BrowserContext, close: () => Promise }> | undefined; + private _browserContextFactory: BrowserContextFactory; + private _tabs: Tab[] = []; + private _currentTab: Tab | undefined; + private _modalStates: (ModalState & { tab: Tab })[] = []; + private _pendingAction: PendingAction | undefined; + private _downloads: { download: playwright.Download, finished: boolean, outputFile: string }[] = []; + clientVersion: { name: string; version: string; } | undefined; + + constructor(tools: Tool[], config: FullConfig, browserContextFactory: BrowserContextFactory) { + this.tools = tools; + this.config = config; + this._browserContextFactory = browserContextFactory; + testDebug('create context'); } - private _onPageClose() { - const browser = this._browser; - const page = this._page; - void page?.context()?.close().then(() => browser?.close()).catch(() => {}); + clientSupportsImages(): boolean { + if (this.config.imageResponses === 'allow') + return true; + if (this.config.imageResponses === 'omit') + return false; + return !this.clientVersion?.name.includes('cursor'); + } - this._createPagePromise = undefined; - this._browser = undefined; - this._page = undefined; - this._fileChooser = undefined; - this._console.length = 0; + modalStates(): ModalState[] { + return this._modalStates; } - existingPage(): playwright.Page { - if (!this._page) - throw new Error('Navigate to a location to create a page'); - return this._page; + setModalState(modalState: ModalState, inTab: Tab) { + this._modalStates.push({ ...modalState, tab: inTab }); } - async console(): Promise { - return this._console; + clearModalState(modalState: ModalState) { + this._modalStates = this._modalStates.filter(state => state !== modalState); } - async close() { - if (!this._page) + 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.tools.find(tool => tool.clearsModalState === state.type); + result.push(`- [${state.description}]: can be handled by the "${tool?.schema.name}" tool`); + } + return result; + } + + tabs(): Tab[] { + return this._tabs; + } + + currentTabOrDie(): Tab { + if (!this._currentTab) + throw new Error('No current snapshot available. Capture a snapshot or navigate to a new location first.'); + return this._currentTab; + } + + async newTab(): Promise { + const { browserContext } = await this._ensureBrowserContext(); + const page = await browserContext.newPage(); + this._currentTab = this._tabs.find(t => t.page === page)!; + return this._currentTab; + } + + async selectTab(index: number) { + this._currentTab = this._tabs[index - 1]; + await this._currentTab.page.bringToFront(); + } + + async ensureTab(): Promise { + const { browserContext } = await this._ensureBrowserContext(); + if (!this._currentTab) + await browserContext.newPage(); + return this._currentTab!; + } + + async listTabsMarkdown(): Promise { + if (!this._tabs.length) + return '### No tabs open'; + 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 + 1}:${current} [${title}] (${url})`); + } + return lines.join('\n'); + } + + async closeTab(index: number | undefined) { + const tab = index === undefined ? this._currentTab : this._tabs[index - 1]; + await tab?.page.close(); + return await this.listTabsMarkdown(); + } + + async run(tool: Tool, params: Record | undefined) { + // Tab management is done outside of the action() call. + const toolResult = await tool.handle(this, tool.schema.inputSchema.parse(params || {})); + const { code, action, waitForNetwork, captureSnapshot, resultOverride } = toolResult; + const racingAction = action ? () => this._raceAgainstModalDialogs(action) : undefined; + + if (resultOverride) + return resultOverride; + + if (!this._currentTab) { + return { + content: [{ + type: 'text', + text: 'No open pages available. Use the "browser_navigate" tool to navigate to a page first.', + }], + }; + } + + const tab = this.currentTabOrDie(); + // TODO: race against modal dialogs to resolve clicks. + let actionResult: { content?: (ImageContent | TextContent)[] } | undefined; + try { + if (waitForNetwork) + actionResult = await waitForCompletion(this, tab, async () => racingAction?.()) ?? undefined; + else + actionResult = await racingAction?.() ?? undefined; + } finally { + if (captureSnapshot && !this._javaScriptBlocked()) + await tab.captureSnapshot(); + } + + const result: string[] = []; + result.push(`- Ran Playwright code: +\`\`\`js +${code.join('\n')} +\`\`\` +`); + + if (this.modalStates().length) { + result.push(...this.modalStatesMarkdown()); + return { + content: [{ + type: 'text', + text: result.join('\n'), + }], + }; + } + + if (this._downloads.length) { + result.push('', '### 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(''); + } + + if (this.tabs().length > 1) + result.push(await this.listTabsMarkdown(), ''); + + if (this.tabs().length > 1) + result.push('### Current tab'); + + result.push( + `- Page URL: ${tab.page.url()}`, + `- Page Title: ${await tab.title()}` + ); + + if (captureSnapshot && tab.hasSnapshot()) + result.push(tab.snapshotOrDie().text()); + + const content = actionResult?.content ?? []; + + return { + content: [ + ...content, + { + type: 'text', + text: result.join('\n'), + } + ], + }; + } + + async waitForTimeout(time: number) { + if (!this._currentTab || this._javaScriptBlocked()) { + await new Promise(f => setTimeout(f, time)); return; - await this._page.close(); + } + + await callOnPageNoTrace(this._currentTab.page, page => { + return page.evaluate(() => new Promise(f => setTimeout(f, 1000))); + }); + } + + private async _raceAgainstModalDialogs(action: () => Promise): Promise { + this._pendingAction = { + dialogShown: new ManualPromise(), + }; + + let result: ToolActionResult | undefined; + try { + await Promise.race([ + action().then(r => result = r), + this._pendingAction.dialogShown, + ]); + } finally { + this._pendingAction = undefined; + } + return result; + } + + private _javaScriptBlocked(): boolean { + return this._modalStates.some(state => state.type === 'dialog'); } - async submitFileChooser(paths: string[]) { - if (!this._fileChooser) - throw new Error('No file chooser visible'); - await this._fileChooser.setFiles(paths); - this._fileChooser = undefined; + dialogShown(tab: Tab, dialog: playwright.Dialog) { + this.setModalState({ + type: 'dialog', + description: `"${dialog.type()}" dialog with message "${dialog.message()}"`, + dialog, + }, tab); + this._pendingAction?.dialogShown.resolve(); } - hasFileChooser() { - return !!this._fileChooser; + async downloadStarted(tab: Tab, download: playwright.Download) { + const entry = { + download, + finished: false, + outputFile: await outputFile(this.config, download.suggestedFilename()) + }; + this._downloads.push(entry); + await download.saveAs(entry.outputFile); + entry.finished = true; } - clearFileChooser() { - this._fileChooser = undefined; + private _onPageCreated(page: playwright.Page) { + const tab = new Tab(this, page, tab => this._onPageClosed(tab)); + this._tabs.push(tab); + if (!this._currentTab) + this._currentTab = tab; } - private async _createPage(): Promise<{ browser?: playwright.Browser, page: playwright.Page }> { - if (this._options.remoteEndpoint) { - const url = new URL(this._options.remoteEndpoint); - if (this._options.launchOptions) - url.searchParams.set('launch-options', JSON.stringify(this._options.launchOptions)); - const browser = await playwright.chromium.connect(String(url)); - const page = await browser.newPage(); - return { browser, page }; + private _onPageClosed(tab: Tab) { + this._modalStates = this._modalStates.filter(state => state.tab !== tab); + const index = this._tabs.indexOf(tab); + if (index === -1) + return; + this._tabs.splice(index, 1); + + if (this._currentTab === tab) + this._currentTab = this._tabs[Math.min(index, this._tabs.length - 1)]; + if (!this._tabs.length) + void this.close(); + } + + async close() { + if (!this._browserContextPromise) + return; + + testDebug('close context'); + + const promise = this._browserContextPromise; + this._browserContextPromise = undefined; + + await promise.then(async ({ browserContext, close }) => { + if (this.config.saveTrace) + await browserContext.tracing.stop(); + await close(); + }); + } + + private async _setupRequestInterception(context: playwright.BrowserContext) { + if (this.config.network?.allowedOrigins?.length) { + await context.route('**', route => route.abort('blockedbyclient')); + + for (const origin of this.config.network.allowedOrigins) + await context.route(`*://${origin}/**`, route => route.continue()); } - if (this._options.cdpEndpoint) { - const browser = await playwright.chromium.connectOverCDP(this._options.cdpEndpoint); - const browserContext = browser.contexts()[0]; - let [page] = browserContext.pages(); - if (!page) - page = await browserContext.newPage(); - return { browser, page }; + if (this.config.network?.blockedOrigins?.length) { + for (const origin of this.config.network.blockedOrigins) + await context.route(`*://${origin}/**`, route => route.abort('blockedbyclient')); } + } - const context = await playwright.chromium.launchPersistentContext(this._options.userDataDir, this._options.launchOptions); - const [page] = context.pages(); - return { page }; - } - - async allFramesSnapshot() { - const page = this.existingPage(); - const visibleFrames = await page.locator('iframe').filter({ visible: true }).all(); - this._lastSnapshotFrames = visibleFrames.map(frame => frame.contentFrame()); - - const snapshots = await Promise.all([ - page.locator('html').ariaSnapshot({ ref: true }), - ...this._lastSnapshotFrames.map(async (frame, index) => { - const snapshot = await frame.locator('html').ariaSnapshot({ ref: true }); - const args = []; - const src = await frame.owner().getAttribute('src'); - if (src) - args.push(`src=${src}`); - const name = await frame.owner().getAttribute('name'); - if (name) - args.push(`name=${name}`); - return `\n# iframe ${args.join(' ')}\n` + snapshot.replaceAll('[ref=', `[ref=f${index}`); - }) - ]); - - return snapshots.join('\n'); - } - - refLocator(ref: string): playwright.Locator { - const page = this.existingPage(); - let frame: playwright.Frame | playwright.FrameLocator = page.mainFrame(); - const match = ref.match(/^f(\d+)(.*)/); - if (match) { - const frameIndex = parseInt(match[1], 10); - if (!this._lastSnapshotFrames[frameIndex]) - throw new Error(`Frame does not exist. Provide ref from the most current snapshot.`); - frame = this._lastSnapshotFrames[frameIndex]; - ref = match[2]; + private _ensureBrowserContext() { + if (!this._browserContextPromise) { + this._browserContextPromise = this._setupBrowserContext(); + this._browserContextPromise.catch(() => { + this._browserContextPromise = undefined; + }); } + return this._browserContextPromise; + } - return frame.locator(`aria-ref=${ref}`); + private async _setupBrowserContext(): Promise<{ browserContext: playwright.BrowserContext, close: () => Promise }> { + // TODO: move to the browser context factory to make it based on isolation mode. + const result = await this._browserContextFactory.createContext(); + const { browserContext } = result; + await this._setupRequestInterception(browserContext); + for (const page of browserContext.pages()) + this._onPageCreated(page); + browserContext.on('page', page => this._onPageCreated(page)); + if (this.config.saveTrace) { + await browserContext.tracing.start({ + name: 'trace', + screenshots: false, + snapshots: true, + sources: false, + }); + } + return result; } } diff --git a/src/fileUtils.ts b/src/fileUtils.ts new file mode 100644 index 0000000..4155b74 --- /dev/null +++ b/src/fileUtils.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 os from 'node:os'; +import path from 'node:path'; + +import type { FullConfig } from './config.js'; + +export function cacheDir() { + 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); + return path.join(cacheDirectory, 'ms-playwright'); +} + +export async function userDataDir(browserConfig: FullConfig['browser']) { + return path.join(cacheDir(), 'ms-playwright', `mcp-${browserConfig.launchOptions?.channel ?? browserConfig?.browserName}-profile`); +} diff --git a/src/httpServer.ts b/src/httpServer.ts new file mode 100644 index 0000000..9e67bef --- /dev/null +++ b/src/httpServer.ts @@ -0,0 +1,232 @@ +/** + * 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 http from 'http'; +import net from 'net'; + +import mime from 'mime'; + +import { ManualPromise } from './manualPromise.js'; + + +export type ServerRouteHandler = (request: http.IncomingMessage, response: http.ServerResponse) => void; + +export type Transport = { + sendEvent?: (method: string, params: any) => void; + close?: () => void; + onconnect: () => void; + dispatch: (method: string, params: any) => Promise; + onclose: () => void; +}; + +export class HttpServer { + private _server: http.Server; + private _urlPrefixPrecise: string = ''; + private _urlPrefixHumanReadable: string = ''; + private _port: number = 0; + private _routes: { prefix?: string, exact?: string, handler: ServerRouteHandler }[] = []; + + constructor() { + this._server = http.createServer(this._onRequest.bind(this)); + decorateServer(this._server); + } + + server() { + return this._server; + } + + routePrefix(prefix: string, handler: ServerRouteHandler) { + this._routes.push({ prefix, handler }); + } + + routePath(path: string, handler: ServerRouteHandler) { + this._routes.push({ exact: path, handler }); + } + + port(): number { + return this._port; + } + + private async _tryStart(port: number | undefined, host: string) { + const errorPromise = new ManualPromise(); + const errorListener = (error: Error) => errorPromise.reject(error); + this._server.on('error', errorListener); + + try { + this._server.listen(port, host); + await Promise.race([ + new Promise(cb => this._server!.once('listening', cb)), + errorPromise, + ]); + } finally { + this._server.removeListener('error', errorListener); + } + } + + async start(options: { port?: number, preferredPort?: number, host?: string } = {}): Promise { + const host = options.host || 'localhost'; + if (options.preferredPort) { + try { + await this._tryStart(options.preferredPort, host); + } catch (e: any) { + if (!e || !e.message || !e.message.includes('EADDRINUSE')) + throw e; + await this._tryStart(undefined, host); + } + } else { + await this._tryStart(options.port, host); + } + + const address = this._server.address(); + if (typeof address === 'string') { + this._urlPrefixPrecise = address; + this._urlPrefixHumanReadable = address; + } else { + this._port = address!.port; + const resolvedHost = address!.family === 'IPv4' ? address!.address : `[${address!.address}]`; + this._urlPrefixPrecise = `http://${resolvedHost}:${address!.port}`; + this._urlPrefixHumanReadable = `http://${host}:${address!.port}`; + } + } + + async stop() { + await new Promise(cb => this._server!.close(cb)); + } + + urlPrefix(purpose: 'human-readable' | 'precise'): string { + return purpose === 'human-readable' ? this._urlPrefixHumanReadable : this._urlPrefixPrecise; + } + + serveFile(request: http.IncomingMessage, response: http.ServerResponse, absoluteFilePath: string, headers?: { [name: string]: string }): boolean { + try { + for (const [name, value] of Object.entries(headers || {})) + response.setHeader(name, value); + if (request.headers.range) + this._serveRangeFile(request, response, absoluteFilePath); + else + this._serveFile(response, absoluteFilePath); + return true; + } catch (e) { + return false; + } + } + + _serveFile(response: http.ServerResponse, absoluteFilePath: string) { + const content = fs.readFileSync(absoluteFilePath); + response.statusCode = 200; + const contentType = mime.getType(path.extname(absoluteFilePath)) || 'application/octet-stream'; + response.setHeader('Content-Type', contentType); + response.setHeader('Content-Length', content.byteLength); + response.end(content); + } + + _serveRangeFile(request: http.IncomingMessage, response: http.ServerResponse, absoluteFilePath: string) { + const range = request.headers.range; + if (!range || !range.startsWith('bytes=') || range.includes(', ') || [...range].filter(char => char === '-').length !== 1) { + response.statusCode = 400; + return response.end('Bad request'); + } + + // Parse the range header: https://datatracker.ietf.org/doc/html/rfc7233#section-2.1 + const [startStr, endStr] = range.replace(/bytes=/, '').split('-'); + + // Both start and end (when passing to fs.createReadStream) and the range header are inclusive and start counting at 0. + let start: number; + let end: number; + const size = fs.statSync(absoluteFilePath).size; + if (startStr !== '' && endStr === '') { + // No end specified: use the whole file + start = +startStr; + end = size - 1; + } else if (startStr === '' && endStr !== '') { + // No start specified: calculate start manually + start = size - +endStr; + end = size - 1; + } else { + start = +startStr; + end = +endStr; + } + + // Handle unavailable range request + if (Number.isNaN(start) || Number.isNaN(end) || start >= size || end >= size || start > end) { + // Return the 416 Range Not Satisfiable: https://datatracker.ietf.org/doc/html/rfc7233#section-4.4 + response.writeHead(416, { + 'Content-Range': `bytes */${size}` + }); + return response.end(); + } + + // Sending Partial Content: https://datatracker.ietf.org/doc/html/rfc7233#section-4.1 + response.writeHead(206, { + 'Content-Range': `bytes ${start}-${end}/${size}`, + 'Accept-Ranges': 'bytes', + 'Content-Length': end - start + 1, + 'Content-Type': mime.getType(path.extname(absoluteFilePath))!, + }); + + const readable = fs.createReadStream(absoluteFilePath, { start, end }); + readable.pipe(response); + } + + private _onRequest(request: http.IncomingMessage, response: http.ServerResponse) { + if (request.method === 'OPTIONS') { + response.writeHead(200); + response.end(); + return; + } + + request.on('error', () => response.end()); + try { + if (!request.url) { + response.end(); + return; + } + const url = new URL('http://localhost' + request.url); + for (const route of this._routes) { + if (route.exact && url.pathname === route.exact) { + route.handler(request, response); + return; + } + if (route.prefix && url.pathname.startsWith(route.prefix)) { + route.handler(request, response); + return; + } + } + response.statusCode = 404; + response.end(); + } catch (e) { + response.end(); + } + } +} + +function decorateServer(server: net.Server) { + const sockets = new Set(); + server.on('connection', socket => { + sockets.add(socket); + socket.once('close', () => sockets.delete(socket)); + }); + + const close = server.close; + server.close = (callback?: (err?: Error) => void) => { + for (const socket of sockets) + socket.destroy(); + sockets.clear(); + return close.call(server, callback); + }; +} diff --git a/src/index.ts b/src/index.ts index d43a359..3c865be 100644 --- a/src/index.ts +++ b/src/index.ts @@ -14,73 +14,32 @@ * limitations under the License. */ -import { createServerWithTools } from './server'; -import * as snapshot from './tools/snapshot'; -import * as common from './tools/common'; -import * as screenshot from './tools/screenshot'; -import { console } from './resources/console'; - -import type { Tool } from './tools/tool'; -import type { Resource } from './resources/resource'; -import type { Server } from '@modelcontextprotocol/sdk/server/index.js'; -import type { LaunchOptions } from 'playwright'; - -const commonTools: Tool[] = [ - common.pressKey, - common.wait, - common.pdf, - common.close, -]; - -const snapshotTools: Tool[] = [ - common.navigate(true), - common.goBack(true), - common.goForward(true), - common.chooseFile(true), - snapshot.snapshot, - snapshot.click, - snapshot.hover, - snapshot.type, - snapshot.selectOption, - snapshot.screenshot, - ...commonTools, -]; - -const screenshotTools: Tool[] = [ - common.navigate(false), - common.goBack(false), - common.goForward(false), - common.chooseFile(false), - screenshot.screenshot, - screenshot.moveMouse, - screenshot.click, - screenshot.drag, - screenshot.type, - ...commonTools, -]; - -const resources: Resource[] = [ - console, -]; +import { Connection, createConnection as createConnectionImpl } from './connection.js'; +import { resolveConfig } from './config.js'; +import { contextFactory } from './browserContextFactory.js'; + +import type { Config } from '../config.js'; +import type { BrowserContext } from 'playwright'; +import type { BrowserContextFactory } from './browserContextFactory.js'; + +export async function createConnection(userConfig: Config = {}, contextGetter?: () => Promise): Promise { + const config = await resolveConfig(userConfig); + const factory = contextGetter ? new SimpleBrowserContextFactory(contextGetter) : contextFactory(config.browser); + return createConnectionImpl(config, factory); +} -type Options = { - userDataDir?: string; - launchOptions?: LaunchOptions; - cdpEndpoint?: string; - vision?: boolean; -}; +class SimpleBrowserContextFactory implements BrowserContextFactory { + private readonly _contextGetter: () => Promise; -const packageJSON = require('../package.json'); + constructor(contextGetter: () => Promise) { + this._contextGetter = contextGetter; + } -export function createServer(options?: Options): Server { - const tools = options?.vision ? screenshotTools : snapshotTools; - return createServerWithTools({ - name: 'Playwright', - version: packageJSON.version, - tools, - resources, - userDataDir: options?.userDataDir ?? '', - launchOptions: options?.launchOptions, - cdpEndpoint: options?.cdpEndpoint, - }); + async createContext(): Promise<{ browserContext: BrowserContext, close: () => Promise }> { + const browserContext = await this._contextGetter(); + return { + browserContext, + close: () => browserContext.close() + }; + } } diff --git a/src/javascript.ts b/src/javascript.ts new file mode 100644 index 0000000..a1fabbd --- /dev/null +++ b/src/javascript.ts @@ -0,0 +1,53 @@ +/** + * 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. + */ + +// adapted from: +// - https://github.com/microsoft/playwright/blob/76ee48dc9d4034536e3ec5b2c7ce8be3b79418a8/packages/playwright-core/src/utils/isomorphic/stringUtils.ts +// - https://github.com/microsoft/playwright/blob/76ee48dc9d4034536e3ec5b2c7ce8be3b79418a8/packages/playwright-core/src/server/codegen/javascript.ts + +// NOTE: this function should not be used to escape any selectors. +export function escapeWithQuotes(text: string, char: string = '\'') { + const stringified = JSON.stringify(text); + const escapedText = stringified.substring(1, stringified.length - 1).replace(/\\"/g, '"'); + if (char === '\'') + return char + escapedText.replace(/[']/g, '\\\'') + char; + if (char === '"') + return char + escapedText.replace(/["]/g, '\\"') + char; + if (char === '`') + return char + escapedText.replace(/[`]/g, '`') + char; + throw new Error('Invalid escape char'); +} + +export function quote(text: string) { + return escapeWithQuotes(text, '\''); +} + +export function formatObject(value: any, indent = ' '): string { + if (typeof value === 'string') + return quote(value); + if (Array.isArray(value)) + return `[${value.map(o => formatObject(o)).join(', ')}]`; + if (typeof value === 'object') { + const keys = Object.keys(value).filter(key => value[key] !== undefined).sort(); + if (!keys.length) + return '{}'; + const tokens: string[] = []; + for (const key of keys) + tokens.push(`${key}: ${formatObject(value[key])}`); + return `{\n${indent}${tokens.join(`,\n${indent}`)}\n}`; + } + return String(value); +} diff --git a/src/manualPromise.ts b/src/manualPromise.ts new file mode 100644 index 0000000..a5034e0 --- /dev/null +++ b/src/manualPromise.ts @@ -0,0 +1,127 @@ +/** + * 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. + */ + +export class ManualPromise extends Promise { + private _resolve!: (t: T) => void; + private _reject!: (e: Error) => void; + private _isDone: boolean; + + constructor() { + let resolve: (t: T) => void; + let reject: (e: Error) => void; + super((f, r) => { + resolve = f; + reject = r; + }); + this._isDone = false; + this._resolve = resolve!; + this._reject = reject!; + } + + isDone() { + return this._isDone; + } + + resolve(t: T) { + this._isDone = true; + this._resolve(t); + } + + reject(e: Error) { + this._isDone = true; + this._reject(e); + } + + static override get [Symbol.species]() { + return Promise; + } + + override get [Symbol.toStringTag]() { + return 'ManualPromise'; + } +} + +export class LongStandingScope { + private _terminateError: Error | undefined; + private _closeError: Error | undefined; + private _terminatePromises = new Map, string[]>(); + private _isClosed = false; + + reject(error: Error) { + this._isClosed = true; + this._terminateError = error; + for (const p of this._terminatePromises.keys()) + p.resolve(error); + } + + close(error: Error) { + this._isClosed = true; + this._closeError = error; + for (const [p, frames] of this._terminatePromises) + p.resolve(cloneError(error, frames)); + } + + isClosed() { + return this._isClosed; + } + + static async raceMultiple(scopes: LongStandingScope[], promise: Promise): Promise { + return Promise.race(scopes.map(s => s.race(promise))); + } + + async race(promise: Promise | Promise[]): Promise { + return this._race(Array.isArray(promise) ? promise : [promise], false) as Promise; + } + + async safeRace(promise: Promise, defaultValue?: T): Promise { + return this._race([promise], true, defaultValue); + } + + private async _race(promises: Promise[], safe: boolean, defaultValue?: any): Promise { + const terminatePromise = new ManualPromise(); + const frames = captureRawStack(); + if (this._terminateError) + terminatePromise.resolve(this._terminateError); + if (this._closeError) + terminatePromise.resolve(cloneError(this._closeError, frames)); + this._terminatePromises.set(terminatePromise, frames); + try { + return await Promise.race([ + terminatePromise.then(e => safe ? defaultValue : Promise.reject(e)), + ...promises + ]); + } finally { + this._terminatePromises.delete(terminatePromise); + } + } +} + +function cloneError(error: Error, frames: string[]) { + const clone = new Error(); + clone.name = error.name; + clone.message = error.message; + clone.stack = [error.name + ':' + error.message, ...frames].join('\n'); + return clone; +} + +function captureRawStack(): string[] { + const stackTraceLimit = Error.stackTraceLimit; + Error.stackTraceLimit = 50; + const error = new Error(); + const stack = error.stack || ''; + Error.stackTraceLimit = stackTraceLimit; + return stack.split('\n'); +} diff --git a/src/resources/console.ts b/src/package.ts similarity index 57% rename from src/resources/console.ts rename to src/package.ts index ca9bea8..a6c7019 100644 --- a/src/resources/console.ts +++ b/src/package.ts @@ -14,22 +14,9 @@ * limitations under the License. */ -import type { Resource } from './resource'; +import fs from 'node:fs'; +import url from 'node:url'; +import path from 'node:path'; -export const console: Resource = { - schema: { - uri: 'browser://console', - name: 'Page console', - mimeType: 'text/plain', - }, - - read: async (context, uri) => { - const messages = await context.console(); - const log = messages.map(message => `[${message.type().toUpperCase()}] ${message.text()}`).join('\n'); - return [{ - uri, - mimeType: 'text/plain', - text: log - }]; - }, -}; +const __filename = url.fileURLToPath(import.meta.url); +export const packageJSON = JSON.parse(fs.readFileSync(path.join(path.dirname(__filename), '..', 'package.json'), 'utf8')); diff --git a/src/pageSnapshot.ts b/src/pageSnapshot.ts new file mode 100644 index 0000000..5f2f07d --- /dev/null +++ b/src/pageSnapshot.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 * as playwright from 'playwright'; +import { callOnPageNoTrace } from './tools/utils.js'; + +type PageEx = playwright.Page & { + _snapshotForAI: () => Promise; +}; + +export class PageSnapshot { + private _page: playwright.Page; + private _text!: string; + + constructor(page: playwright.Page) { + this._page = page; + } + + static async create(page: playwright.Page): Promise { + const snapshot = new PageSnapshot(page); + await snapshot._build(); + return snapshot; + } + + text(): string { + return this._text; + } + + private async _build() { + const snapshot = await callOnPageNoTrace(this._page, page => (page as PageEx)._snapshotForAI()); + this._text = [ + `- Page Snapshot`, + '```yaml', + snapshot, + '```', + ].join('\n'); + } + + refLocator(params: { element: string, ref: string }): playwright.Locator { + return this._page.locator(`aria-ref=${params.ref}`).describe(params.element); + } +} diff --git a/src/program.ts b/src/program.ts index c196106..537a244 100644 --- a/src/program.ts +++ b/src/program.ts @@ -14,136 +14,65 @@ * limitations under the License. */ -import http from 'http'; -import fs from 'fs'; -import os from 'os'; -import path from 'path'; - import { program } from 'commander'; -import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'; -import { SSEServerTransport } from '@modelcontextprotocol/sdk/server/sse.js'; - - -import { createServer } from './index'; -import { ServerList } from './server'; - -import type { LaunchOptions } from 'playwright'; -import assert from 'assert'; +// @ts-ignore +import { startTraceViewerServer } from 'playwright-core/lib/server'; -const packageJSON = require('../package.json'); +import { startHttpTransport, startStdioTransport } from './transport.js'; +import { resolveCLIConfig } from './config.js'; +import { Server } from './server.js'; +import { packageJSON } from './package.js'; program .version('Version ' + packageJSON.version) .name(packageJSON.name) - .option('--headless', 'Run browser in headless mode, headed by default') - .option('--user-data-dir ', 'Path to the user data directory') - .option('--vision', 'Run server that uses screenshots (Aria snapshots are used by default)') - .option('--port ', 'Port to listen on for SSE transport.') + .option('--allowed-origins ', 'semicolon-separated list of origins to allow the browser to request. Default is to allow all.', semicolonSeparatedList) + .option('--blocked-origins ', 'semicolon-separated list of origins to block the browser from requesting. Blocklist is evaluated before allowlist. If used without the allowlist, requests not matching the blocklist are still allowed.', semicolonSeparatedList) + .option('--block-service-workers', 'block service workers') + .option('--browser ', 'browser or chrome channel to use, possible values: chrome, firefox, webkit, msedge.') + .option('--browser-agent ', 'Use browser agent (experimental).') + .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('--config ', 'path to the configuration file.') + .option('--device ', 'device to emulate, for example: "iPhone 15"') + .option('--executable-path ', 'path to the browser executable.') + .option('--headless', 'run browser in headless mode, headed by default') + .option('--host ', 'host to bind server to. Default is localhost. Use 0.0.0.0 to bind to all interfaces.') + .option('--ignore-https-errors', 'ignore https errors') + .option('--isolated', 'keep the browser profile in memory, do not save it to disk.') + .option('--image-responses ', 'whether to send image responses to the client. Can be "allow", "omit", or "auto". Defaults to "auto", which sends images if the client can display them.') + .option('--no-sandbox', 'disable the sandbox for all process types that are normally sandboxed.') + .option('--output-dir ', 'path to the directory for output files.') + .option('--port ', 'port to listen on for SSE transport.') + .option('--proxy-bypass ', 'comma-separated domains to bypass proxy, for example ".com,chromium.org,.domain.com"') + .option('--proxy-server ', 'specify proxy server, for example "http://myproxy:3128" or "socks5://myproxy:8080"') + .option('--save-trace', 'Whether to save the Playwright Trace of the session into the output directory.') + .option('--storage-state ', 'path to the storage state file for isolated sessions.') + .option('--user-agent ', 'specify user agent string') + .option('--user-data-dir ', 'path to the user data directory. If not specified, a temporary directory will be created.') + .option('--viewport-size ', 'specify browser viewport size in pixels, for example "1280, 720"') + .option('--vision', 'Run server that uses screenshots (Aria snapshots are used by default)') .action(async options => { - const launchOptions: LaunchOptions = { - headless: !!options.headless, - channel: 'chrome', - }; - const userDataDir = options.userDataDir ?? await createUserDataDir(); - const serverList = new ServerList(() => createServer({ - userDataDir, - launchOptions, - vision: !!options.vision, - cdpEndpoint: options.cdpEndpoint, - })); - setupExitWatchdog(serverList); + const config = await resolveCLIConfig(options); + const server = new Server(config); + server.setupExitWatchdog(); - if (options.port) { - startSSEServer(+options.port, serverList); - } else { - const server = await serverList.create(); - await server.connect(new StdioServerTransport()); + if (config.server.port !== undefined) + startHttpTransport(server); + else + await startStdioTransport(server); + + if (config.saveTrace) { + const server = await startTraceViewerServer(); + const urlPrefix = server.urlPrefix('human-readable'); + const url = urlPrefix + '/trace/index.html?trace=' + config.browser.launchOptions.tracesDir + '/trace.json'; + // eslint-disable-next-line no-console + console.error('\nTrace viewer listening on ' + url); } }); -function setupExitWatchdog(serverList: ServerList) { - process.stdin.on('close', async () => { - setTimeout(() => process.exit(0), 15000); - await serverList.closeAll(); - process.exit(0); - }); -} - -program.parse(process.argv); - -async function 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-chrome-profile'); - await fs.promises.mkdir(result, { recursive: true }); - return result; +function semicolonSeparatedList(value: string): string[] { + return value.split(';').map(v => v.trim()); } -async function startSSEServer(port: number, serverList: ServerList) { - const sessions = new Map(); - const httpServer = http.createServer(async (req, res) => { - if (req.method === 'POST') { - const searchParams = new URL(`http://localhost${req.url}`).searchParams; - const sessionId = searchParams.get('sessionId'); - if (!sessionId) { - res.statusCode = 400; - res.end('Missing sessionId'); - return; - } - const transport = sessions.get(sessionId); - if (!transport) { - res.statusCode = 404; - res.end('Session not found'); - return; - } - - await transport.handlePostMessage(req, res); - return; - } else if (req.method === 'GET') { - const transport = new SSEServerTransport('/sse', res); - sessions.set(transport.sessionId, transport); - const server = await serverList.create(); - res.on('close', () => { - sessions.delete(transport.sessionId); - serverList.close(server).catch(e => console.error(e)); - }); - await server.connect(transport); - return; - } else { - res.statusCode = 405; - res.end('Method not allowed'); - } - }); - - httpServer.listen(port, () => { - const address = httpServer.address(); - assert(address, 'Could not bind server socket'); - let url: string; - if (typeof address === 'string') { - url = address; - } else { - const resolvedPort = address.port; - let resolvedHost = address.family === 'IPv4' ? address.address : `[${address.address}]`; - if (resolvedHost === '0.0.0.0' || resolvedHost === '[::]') - resolvedHost = 'localhost'; - url = `http://${resolvedHost}:${resolvedPort}`; - } - console.log(`Listening on ${url}`); - console.log('Put this in your client config:'); - console.log(JSON.stringify({ - 'mcpServers': { - 'playwright': { - 'url': `${url}/sse` - } - } - }, undefined, 2)); - }); -} +void program.parseAsync(process.argv); diff --git a/src/resources/resource.ts b/src/resources/resource.ts index d9b19e4..abe0e5b 100644 --- a/src/resources/resource.ts +++ b/src/resources/resource.ts @@ -14,7 +14,7 @@ * limitations under the License. */ -import type { Context } from '../context'; +import type { Context } from '../context.js'; export type ResourceSchema = { uri: string; diff --git a/src/server.ts b/src/server.ts index 2b20f98..8c143e1 100644 --- a/src/server.ts +++ b/src/server.ts @@ -14,101 +14,46 @@ * limitations under the License. */ -import { Server } from '@modelcontextprotocol/sdk/server/index.js'; -import { CallToolRequestSchema, ListResourcesRequestSchema, ListToolsRequestSchema, ReadResourceRequestSchema } from '@modelcontextprotocol/sdk/types.js'; - -import { Context } from './context'; - -import type { Tool } from './tools/tool'; -import type { Resource } from './resources/resource'; -import type { ContextOptions } from './context'; - -type Options = ContextOptions & { - name: string; - version: string; - tools: Tool[]; - resources: Resource[], -}; - -export function createServerWithTools(options: Options): Server { - const { name, version, tools, resources } = options; - const context = new Context(options); - const server = new Server({ name, version }, { - capabilities: { - tools: {}, - resources: {}, - } - }); - - server.setRequestHandler(ListToolsRequestSchema, async () => { - return { tools: tools.map(tool => tool.schema) }; - }); - - server.setRequestHandler(ListResourcesRequestSchema, async () => { - return { resources: resources.map(resource => resource.schema) }; - }); - - server.setRequestHandler(CallToolRequestSchema, async request => { - const tool = tools.find(tool => tool.schema.name === request.params.name); - if (!tool) { - return { - content: [{ type: 'text', text: `Tool "${request.params.name}" not found` }], - isError: true, - }; - } - - try { - const result = await tool.handle(context, request.params.arguments); - return result; - } catch (error) { - return { - content: [{ type: 'text', text: String(error) }], - isError: true, - }; - } - }); - - server.setRequestHandler(ReadResourceRequestSchema, async request => { - const resource = resources.find(resource => resource.schema.uri === request.params.uri); - if (!resource) - return { contents: [] }; - - const contents = await resource.read(context, request.params.uri); - return { contents }; - }); - - const oldClose = server.close.bind(server); - - server.close = async () => { - await oldClose(); - await context.close(); - }; - - return server; -} - -export class ServerList { - private _servers: Server[] = []; - private _serverFactory: () => Server; - - constructor(serverFactory: () => Server) { - this._serverFactory = serverFactory; - } - - async create() { - const server = this._serverFactory(); - this._servers.push(server); - return server; +import { createConnection } from './connection.js'; +import { contextFactory } from './browserContextFactory.js'; + +import type { FullConfig } from './config.js'; +import type { Connection } from './connection.js'; +import type { Transport } from '@modelcontextprotocol/sdk/shared/transport.js'; +import type { BrowserContextFactory } from './browserContextFactory.js'; + +export class Server { + readonly config: FullConfig; + private _connectionList: Connection[] = []; + private _browserConfig: FullConfig['browser']; + private _contextFactory: BrowserContextFactory; + + constructor(config: FullConfig) { + this.config = config; + this._browserConfig = config.browser; + this._contextFactory = contextFactory(this._browserConfig); } - async close(server: Server) { - const index = this._servers.indexOf(server); - if (index !== -1) - this._servers.splice(index, 1); - await server.close(); + async createConnection(transport: Transport): Promise { + const connection = createConnection(this.config, this._contextFactory); + this._connectionList.push(connection); + await connection.server.connect(transport); + return connection; } - async closeAll() { - await Promise.all(this._servers.map(server => server.close())); + setupExitWatchdog() { + let isExiting = false; + const handleExit = async () => { + if (isExiting) + return; + isExiting = true; + setTimeout(() => process.exit(0), 15000); + await Promise.all(this._connectionList.map(connection => connection.close())); + process.exit(0); + }; + + process.stdin.on('close', handleExit); + process.on('SIGINT', handleExit); + process.on('SIGTERM', handleExit); } } diff --git a/src/tab.ts b/src/tab.ts new file mode 100644 index 0000000..5d4e93a --- /dev/null +++ b/src/tab.ts @@ -0,0 +1,121 @@ +/** + * 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 * as playwright from 'playwright'; + +import { PageSnapshot } from './pageSnapshot.js'; + +import type { Context } from './context.js'; +import { callOnPageNoTrace } from './tools/utils.js'; + +export class Tab { + readonly context: Context; + readonly page: playwright.Page; + private _consoleMessages: playwright.ConsoleMessage[] = []; + private _requests: Map = new Map(); + private _snapshot: PageSnapshot | undefined; + private _onPageClose: (tab: Tab) => void; + + constructor(context: Context, page: playwright.Page, onPageClose: (tab: Tab) => void) { + this.context = context; + this.page = page; + this._onPageClose = onPageClose; + page.on('console', event => this._consoleMessages.push(event)); + page.on('request', request => this._requests.set(request, null)); + page.on('response', response => this._requests.set(response.request(), response)); + page.on('close', () => this._onClose()); + page.on('filechooser', chooser => { + this.context.setModalState({ + type: 'fileChooser', + description: 'File chooser', + fileChooser: chooser, + }, this); + }); + page.on('dialog', dialog => this.context.dialogShown(this, dialog)); + page.on('download', download => { + void this.context.downloadStarted(this, download); + }); + page.setDefaultNavigationTimeout(60000); + page.setDefaultTimeout(5000); + } + + private _clearCollectedArtifacts() { + this._consoleMessages.length = 0; + this._requests.clear(); + } + + private _onClose() { + this._clearCollectedArtifacts(); + this._onPageClose(this); + } + + async title(): Promise { + return await callOnPageNoTrace(this.page, page => page.title()); + } + + async waitForLoadState(state: 'load', options?: { timeout?: number }): Promise { + await callOnPageNoTrace(this.page, page => page.waitForLoadState(state, options).catch(() => {})); + } + + async navigate(url: string) { + this._clearCollectedArtifacts(); + + const downloadEvent = callOnPageNoTrace(this.page, page => page.waitForEvent('download').catch(() => {})); + try { + await this.page.goto(url, { waitUntil: 'domcontentloaded' }); + } catch (_e: unknown) { + const e = _e as Error; + const mightBeDownload = + e.message.includes('net::ERR_ABORTED') // chromium + || e.message.includes('Download is starting'); // firefox + webkit + if (!mightBeDownload) + throw e; + + // on chromium, the download event is fired *after* page.goto rejects, so we wait a lil bit + const download = await Promise.race([ + downloadEvent, + new Promise(resolve => setTimeout(resolve, 500)), + ]); + if (!download) + throw e; + } + + // Cap load event to 5 seconds, the page is operational at this point. + await this.waitForLoadState('load', { timeout: 5000 }); + } + + hasSnapshot(): boolean { + return !!this._snapshot; + } + + snapshotOrDie(): PageSnapshot { + if (!this._snapshot) + throw new Error('No snapshot available'); + return this._snapshot; + } + + consoleMessages(): playwright.ConsoleMessage[] { + return this._consoleMessages; + } + + requests(): Map { + return this._requests; + } + + async captureSnapshot() { + this._snapshot = await PageSnapshot.create(this.page); + } +} diff --git a/src/tools.ts b/src/tools.ts new file mode 100644 index 0000000..bd6db0f --- /dev/null +++ b/src/tools.ts @@ -0,0 +1,66 @@ +/** + * 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 common from './tools/common.js'; +import console from './tools/console.js'; +import dialogs from './tools/dialogs.js'; +import files from './tools/files.js'; +import install from './tools/install.js'; +import keyboard from './tools/keyboard.js'; +import navigate from './tools/navigate.js'; +import network from './tools/network.js'; +import pdf from './tools/pdf.js'; +import snapshot from './tools/snapshot.js'; +import tabs from './tools/tabs.js'; +import screenshot from './tools/screenshot.js'; +import testing from './tools/testing.js'; +import vision from './tools/vision.js'; +import wait from './tools/wait.js'; + +import type { Tool } from './tools/tool.js'; + +export const snapshotTools: Tool[] = [ + ...common(true), + ...console, + ...dialogs(true), + ...files(true), + ...install, + ...keyboard(true), + ...navigate(true), + ...network, + ...pdf, + ...screenshot, + ...snapshot, + ...tabs(true), + ...testing, + ...wait(true), +]; + +export const visionTools: Tool[] = [ + ...common(false), + ...console, + ...dialogs(false), + ...files(false), + ...install, + ...keyboard(false), + ...navigate(false), + ...network, + ...pdf, + ...tabs(false), + ...testing, + ...vision, + ...wait(false), +]; diff --git a/src/tools/common.ts b/src/tools/common.ts index a04ddaf..8a16c35 100644 --- a/src/tools/common.ts +++ b/src/tools/common.ts @@ -14,163 +14,65 @@ * limitations under the License. */ -import os from 'os'; -import path from 'path'; - import { z } from 'zod'; -import { zodToJsonSchema } from 'zod-to-json-schema'; - -import { captureAriaSnapshot, runAndWait } from './utils'; - -import type { ToolFactory, Tool } from './tool'; - -const navigateSchema = z.object({ - url: z.string().describe('The URL to navigate to'), -}); - -export const navigate: ToolFactory = snapshot => ({ - schema: { - name: 'browser_navigate', - description: 'Navigate to a URL', - inputSchema: zodToJsonSchema(navigateSchema), - }, - handle: async (context, params) => { - const validatedParams = navigateSchema.parse(params); - const page = await context.createPage(); - await page.goto(validatedParams.url, { waitUntil: 'domcontentloaded' }); - // Cap load event to 5 seconds, the page is operational at this point. - await page.waitForLoadState('load', { timeout: 5000 }).catch(() => {}); - if (snapshot) - return captureAriaSnapshot(context); - return { - content: [{ - type: 'text', - text: `Navigated to ${validatedParams.url}`, - }], - }; - }, -}); +import { defineTool, type ToolFactory } from './tool.js'; -const goBackSchema = z.object({}); +const close = defineTool({ + capability: 'core', -export const goBack: ToolFactory = snapshot => ({ schema: { - name: 'browser_go_back', - description: 'Go back to the previous page', - inputSchema: zodToJsonSchema(goBackSchema), - }, - handle: async context => { - return await runAndWait(context, 'Navigated back', async page => page.goBack(), snapshot); + name: 'browser_close', + title: 'Close browser', + description: 'Close the page', + inputSchema: z.object({}), + type: 'readOnly', }, -}); - -const goForwardSchema = z.object({}); -export const goForward: ToolFactory = snapshot => ({ - schema: { - name: 'browser_go_forward', - description: 'Go forward to the next page', - inputSchema: zodToJsonSchema(goForwardSchema), - }, handle: async context => { - return await runAndWait(context, 'Navigated forward', async page => page.goForward(), snapshot); - }, -}); - -const waitSchema = z.object({ - time: z.number().describe('The time to wait in seconds'), -}); - -export const wait: Tool = { - schema: { - name: 'browser_wait', - description: 'Wait for a specified time in seconds', - inputSchema: zodToJsonSchema(waitSchema), - }, - handle: async (context, params) => { - const validatedParams = waitSchema.parse(params); - await new Promise(f => setTimeout(f, Math.min(10000, validatedParams.time * 1000))); + await context.close(); return { - content: [{ - type: 'text', - text: `Waited for ${validatedParams.time} seconds`, - }], + code: [`await page.close()`], + captureSnapshot: false, + waitForNetwork: false, }; }, -}; - -const pressKeySchema = z.object({ - key: z.string().describe('Name of the key to press or a character to generate, such as `ArrowLeft` or `a`'), }); -export const pressKey: Tool = { +const resize: ToolFactory = captureSnapshot => defineTool({ + capability: 'core', schema: { - name: 'browser_press_key', - description: 'Press a key on the keyboard', - inputSchema: zodToJsonSchema(pressKeySchema), + name: 'browser_resize', + title: 'Resize browser window', + description: 'Resize the browser window', + inputSchema: z.object({ + width: z.number().describe('Width of the browser window'), + height: z.number().describe('Height of the browser window'), + }), + type: 'readOnly', }, + handle: async (context, params) => { - const validatedParams = pressKeySchema.parse(params); - return await runAndWait(context, `Pressed key ${validatedParams.key}`, async page => { - await page.keyboard.press(validatedParams.key); - }); - }, -}; + const tab = context.currentTabOrDie(); -const pdfSchema = z.object({}); + const code = [ + `// Resize browser window to ${params.width}x${params.height}`, + `await page.setViewportSize({ width: ${params.width}, height: ${params.height} });` + ]; -export const pdf: Tool = { - schema: { - name: 'browser_save_as_pdf', - description: 'Save page as PDF', - inputSchema: zodToJsonSchema(pdfSchema), - }, - handle: async context => { - const page = context.existingPage(); - const fileName = path.join(os.tmpdir(), `/page-${new Date().toISOString()}.pdf`); - await page.pdf({ path: fileName }); - return { - content: [{ - type: 'text', - text: `Saved as ${fileName}`, - }], + const action = async () => { + await tab.page.setViewportSize({ width: params.width, height: params.height }); }; - }, -}; - -const closeSchema = z.object({}); -export const close: Tool = { - schema: { - name: 'browser_close', - description: 'Close the page', - inputSchema: zodToJsonSchema(closeSchema), - }, - handle: async context => { - await context.close(); return { - content: [{ - type: 'text', - text: `Page closed`, - }], + code, + action, + captureSnapshot, + waitForNetwork: true }; }, -}; - -const chooseFileSchema = z.object({ - paths: z.array(z.string()).describe('The absolute paths to the files to upload. Can be a single file or multiple files.'), }); -export const chooseFile: ToolFactory = snapshot => ({ - schema: { - name: 'browser_choose_file', - description: 'Choose one or multiple files to upload', - inputSchema: zodToJsonSchema(chooseFileSchema), - }, - handle: async (context, params) => { - const validatedParams = chooseFileSchema.parse(params); - return await runAndWait(context, `Chose files ${validatedParams.paths.join(', ')}`, async () => { - await context.submitFileChooser(validatedParams.paths); - }, snapshot); - }, -}); +export default (captureSnapshot: boolean) => [ + close, + resize(captureSnapshot) +]; diff --git a/src/tools/console.ts b/src/tools/console.ts new file mode 100644 index 0000000..45bf3d7 --- /dev/null +++ b/src/tools/console.ts @@ -0,0 +1,47 @@ +/** + * Copyright (c) Microsoft Corporation. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { z } from 'zod'; +import { defineTool } from './tool.js'; + +const console = defineTool({ + capability: 'core', + schema: { + name: 'browser_console_messages', + title: 'Get console messages', + description: 'Returns all console messages', + inputSchema: z.object({}), + type: 'readOnly', + }, + handle: async context => { + const messages = context.currentTabOrDie().consoleMessages(); + const log = messages.map(message => `[${message.type().toUpperCase()}] ${message.text()}`).join('\n'); + return { + code: [`// `], + action: async () => { + return { + content: [{ type: 'text', text: log }] + }; + }, + captureSnapshot: false, + waitForNetwork: false, + }; + }, +}); + +export default [ + console, +]; diff --git a/src/tools/dialogs.ts b/src/tools/dialogs.ts new file mode 100644 index 0000000..348e461 --- /dev/null +++ b/src/tools/dialogs.ts @@ -0,0 +1,62 @@ +/** + * Copyright (c) Microsoft Corporation. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { z } from 'zod'; +import { defineTool, type ToolFactory } from './tool.js'; + +const handleDialog: ToolFactory = captureSnapshot => defineTool({ + capability: 'core', + + schema: { + name: 'browser_handle_dialog', + title: 'Handle a dialog', + description: 'Handle a dialog', + inputSchema: z.object({ + accept: z.boolean().describe('Whether to accept the dialog.'), + promptText: z.string().optional().describe('The text of the prompt in case of a prompt dialog.'), + }), + type: 'destructive', + }, + + handle: async (context, params) => { + const dialogState = context.modalStates().find(state => state.type === 'dialog'); + if (!dialogState) + throw new Error('No dialog visible'); + + if (params.accept) + await dialogState.dialog.accept(params.promptText); + else + await dialogState.dialog.dismiss(); + + context.clearModalState(dialogState); + + const code = [ + `// `, + ]; + + return { + code, + captureSnapshot, + waitForNetwork: false, + }; + }, + + clearsModalState: 'dialog', +}); + +export default (captureSnapshot: boolean) => [ + handleDialog(captureSnapshot), +]; diff --git a/src/tools/files.ts b/src/tools/files.ts new file mode 100644 index 0000000..2dc7837 --- /dev/null +++ b/src/tools/files.ts @@ -0,0 +1,59 @@ +/** + * Copyright (c) Microsoft Corporation. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { z } from 'zod'; +import { defineTool, type ToolFactory } from './tool.js'; + +const uploadFile: ToolFactory = captureSnapshot => defineTool({ + capability: 'files', + + schema: { + name: 'browser_file_upload', + title: 'Upload files', + description: 'Upload one or multiple files', + inputSchema: z.object({ + paths: z.array(z.string()).describe('The absolute paths to the files to upload. Can be a single file or multiple files.'), + }), + type: 'destructive', + }, + + handle: async (context, params) => { + const modalState = context.modalStates().find(state => state.type === 'fileChooser'); + if (!modalState) + throw new Error('No file chooser visible'); + + const code = [ + `// { + await modalState.fileChooser.setFiles(params.paths); + context.clearModalState(modalState); + }; + + return { + code, + action, + captureSnapshot, + waitForNetwork: true, + }; + }, + clearsModalState: 'fileChooser', +}); + +export default (captureSnapshot: boolean) => [ + uploadFile(captureSnapshot), +]; diff --git a/src/tools/install.ts b/src/tools/install.ts new file mode 100644 index 0000000..d0d5145 --- /dev/null +++ b/src/tools/install.ts @@ -0,0 +1,63 @@ +/** + * 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 { fork } from 'child_process'; +import path from 'path'; + +import { z } from 'zod'; +import { defineTool } from './tool.js'; + +import { fileURLToPath } from 'node:url'; + +const install = defineTool({ + capability: 'install', + schema: { + name: 'browser_install', + title: 'Install the browser specified in the config', + description: 'Install the browser specified in the config. Call this if you get an error about the browser not being installed.', + inputSchema: z.object({}), + type: 'destructive', + }, + + handle: async context => { + const channel = context.config.browser?.launchOptions?.channel ?? context.config.browser?.browserName ?? 'chrome'; + const cliUrl = import.meta.resolve('playwright/package.json'); + const cliPath = path.join(fileURLToPath(cliUrl), '..', 'cli.js'); + const child = fork(cliPath, ['install', channel], { + stdio: 'pipe', + }); + const output: string[] = []; + child.stdout?.on('data', data => output.push(data.toString())); + child.stderr?.on('data', data => output.push(data.toString())); + await new Promise((resolve, reject) => { + child.on('close', code => { + if (code === 0) + resolve(); + else + reject(new Error(`Failed to install browser: ${output.join('')}`)); + }); + }); + return { + code: [`// Browser ${channel} installed`], + captureSnapshot: false, + waitForNetwork: false, + }; + }, +}); + +export default [ + install, +]; diff --git a/src/tools/keyboard.ts b/src/tools/keyboard.ts new file mode 100644 index 0000000..521aab2 --- /dev/null +++ b/src/tools/keyboard.ts @@ -0,0 +1,54 @@ +/** + * Copyright (c) Microsoft Corporation. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { z } from 'zod'; +import { defineTool, type ToolFactory } from './tool.js'; + +const pressKey: ToolFactory = captureSnapshot => defineTool({ + capability: 'core', + + schema: { + name: 'browser_press_key', + title: 'Press a key', + description: 'Press a key on the keyboard', + inputSchema: z.object({ + key: z.string().describe('Name of the key to press or a character to generate, such as `ArrowLeft` or `a`'), + }), + type: 'destructive', + }, + + handle: async (context, params) => { + const tab = context.currentTabOrDie(); + + const code = [ + `// Press ${params.key}`, + `await page.keyboard.press('${params.key}');`, + ]; + + const action = () => tab.page.keyboard.press(params.key); + + return { + code, + action, + captureSnapshot, + waitForNetwork: true + }; + }, +}); + +export default (captureSnapshot: boolean) => [ + pressKey(captureSnapshot), +]; diff --git a/src/tools/navigate.ts b/src/tools/navigate.ts new file mode 100644 index 0000000..501576e --- /dev/null +++ b/src/tools/navigate.ts @@ -0,0 +1,104 @@ +/** + * Copyright (c) Microsoft Corporation. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { z } from 'zod'; +import { defineTool, type ToolFactory } from './tool.js'; + +const navigate: ToolFactory = captureSnapshot => defineTool({ + capability: 'core', + + schema: { + name: 'browser_navigate', + title: 'Navigate to a URL', + description: 'Navigate to a URL', + inputSchema: z.object({ + url: z.string().describe('The URL to navigate to'), + }), + type: 'destructive', + }, + + handle: async (context, params) => { + const tab = await context.ensureTab(); + await tab.navigate(params.url); + + const code = [ + `// Navigate to ${params.url}`, + `await page.goto('${params.url}');`, + ]; + + return { + code, + captureSnapshot, + waitForNetwork: false, + }; + }, +}); + +const goBack: ToolFactory = captureSnapshot => defineTool({ + capability: 'history', + schema: { + name: 'browser_navigate_back', + title: 'Go back', + description: 'Go back to the previous page', + inputSchema: z.object({}), + type: 'readOnly', + }, + + handle: async context => { + const tab = await context.ensureTab(); + await tab.page.goBack(); + const code = [ + `// Navigate back`, + `await page.goBack();`, + ]; + + return { + code, + captureSnapshot, + waitForNetwork: false, + }; + }, +}); + +const goForward: ToolFactory = captureSnapshot => defineTool({ + capability: 'history', + schema: { + name: 'browser_navigate_forward', + title: 'Go forward', + description: 'Go forward to the next page', + inputSchema: z.object({}), + type: 'readOnly', + }, + handle: async context => { + const tab = context.currentTabOrDie(); + await tab.page.goForward(); + const code = [ + `// Navigate forward`, + `await page.goForward();`, + ]; + return { + code, + captureSnapshot, + waitForNetwork: false, + }; + }, +}); + +export default (captureSnapshot: boolean) => [ + navigate(captureSnapshot), + goBack(captureSnapshot), + goForward(captureSnapshot), +]; diff --git a/src/tools/network.ts b/src/tools/network.ts new file mode 100644 index 0000000..9e1946c --- /dev/null +++ b/src/tools/network.ts @@ -0,0 +1,59 @@ +/** + * Copyright (c) Microsoft Corporation. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { z } from 'zod'; +import { defineTool } from './tool.js'; + +import type * as playwright from 'playwright'; + +const requests = defineTool({ + capability: 'core', + + schema: { + name: 'browser_network_requests', + title: 'List network requests', + description: 'Returns all network requests since loading the page', + inputSchema: z.object({}), + type: 'readOnly', + }, + + handle: async context => { + const requests = context.currentTabOrDie().requests(); + const log = [...requests.entries()].map(([request, response]) => renderRequest(request, response)).join('\n'); + return { + code: [`// `], + action: async () => { + return { + content: [{ type: 'text', text: log }] + }; + }, + captureSnapshot: false, + waitForNetwork: false, + }; + }, +}); + +function renderRequest(request: playwright.Request, response: playwright.Response | null) { + const result: string[] = []; + result.push(`[${request.method().toUpperCase()}] ${request.url()}`); + if (response) + result.push(`=> [${response.status()}] ${response.statusText()}`); + return result.join(' '); +} + +export default [ + requests, +]; diff --git a/src/tools/pdf.ts b/src/tools/pdf.ts new file mode 100644 index 0000000..c020f03 --- /dev/null +++ b/src/tools/pdf.ts @@ -0,0 +1,58 @@ +/** + * Copyright (c) Microsoft Corporation. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { z } from 'zod'; +import { defineTool } 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.'), +}); + +const pdf = defineTool({ + capability: 'pdf', + + schema: { + name: 'browser_pdf_save', + title: 'Save as PDF', + description: 'Save page as PDF', + inputSchema: pdfSchema, + type: 'readOnly', + }, + + handle: async (context, params) => { + const tab = context.currentTabOrDie(); + const fileName = await outputFile(context.config, params.filename ?? `page-${new Date().toISOString()}.pdf`); + + const code = [ + `// Save page as ${fileName}`, + `await page.pdf(${javascript.formatObject({ path: fileName })});`, + ]; + + return { + code, + action: async () => tab.page.pdf({ path: fileName }).then(() => {}), + captureSnapshot: false, + waitForNetwork: false, + }; + }, +}); + +export default [ + pdf, +]; diff --git a/src/tools/screenshot.ts b/src/tools/screenshot.ts index cef2ba1..439d79a 100644 --- a/src/tools/screenshot.ts +++ b/src/tools/screenshot.ts @@ -15,119 +15,76 @@ */ import { z } from 'zod'; -import { zodToJsonSchema } from 'zod-to-json-schema'; -import { runAndWait } from './utils'; - -import type { Tool } from './tool'; - -export const screenshot: Tool = { - schema: { - name: 'browser_screenshot', - description: 'Take a screenshot of the current page', - inputSchema: zodToJsonSchema(z.object({})), - }, - - handle: async context => { - const page = context.existingPage(); - const screenshot = await page.screenshot({ type: 'jpeg', quality: 50, scale: 'css' }); - return { - content: [{ type: 'image', data: screenshot.toString('base64'), mimeType: 'image/jpeg' }], - }; - }, -}; - -const elementSchema = z.object({ - element: z.string().describe('Human-readable element description used to obtain permission to interact with the element'), +import { defineTool } 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.'), + 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.'), +}).refine(data => { + return !!data.element === !!data.ref; +}, { + message: 'Both element and ref must be provided or neither.', + path: ['ref', 'element'] }); -const moveMouseSchema = elementSchema.extend({ - x: z.number().describe('X coordinate'), - y: z.number().describe('Y coordinate'), -}); - -export const moveMouse: Tool = { +const screenshot = defineTool({ + capability: 'core', schema: { - name: 'browser_move_mouse', - description: 'Move mouse to a given position', - inputSchema: zodToJsonSchema(moveMouseSchema), + name: 'browser_take_screenshot', + 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.`, + inputSchema: screenshotSchema, + type: 'readOnly', }, handle: async (context, params) => { - const validatedParams = moveMouseSchema.parse(params); - const page = context.existingPage(); - await page.mouse.move(validatedParams.x, validatedParams.y); - return { - content: [{ type: 'text', text: `Moved mouse to (${validatedParams.x}, ${validatedParams.y})` }], + const tab = context.currentTabOrDie(); + const snapshot = tab.snapshotOrDie(); + const fileType = params.raw ? 'png' : 'jpeg'; + const fileName = await outputFile(context.config, params.filename ?? `page-${new Date().toISOString()}.${fileType}`); + const options: playwright.PageScreenshotOptions = { type: fileType, quality: fileType === 'png' ? undefined : 50, scale: 'css', path: fileName }; + const isElementScreenshot = params.element && params.ref; + + const code = [ + `// Screenshot ${isElementScreenshot ? params.element : 'viewport'} and save it as ${fileName}`, + ]; + + const locator = params.ref ? snapshot.refLocator({ element: params.element || '', ref: params.ref }) : null; + + if (locator) + code.push(`await page.${await generateLocator(locator)}.screenshot(${javascript.formatObject(options)});`); + else + code.push(`await page.screenshot(${javascript.formatObject(options)});`); + + const includeBase64 = context.clientSupportsImages(); + const action = async () => { + const screenshot = locator ? await locator.screenshot(options) : await tab.page.screenshot(options); + return { + content: includeBase64 ? [{ + type: 'image' as 'image', + data: screenshot.toString('base64'), + mimeType: fileType === 'png' ? 'image/png' : 'image/jpeg', + }] : [] + }; }; - }, -}; - -const clickSchema = elementSchema.extend({ - x: z.number().describe('X coordinate'), - y: z.number().describe('Y coordinate'), -}); - -export const click: Tool = { - schema: { - name: 'browser_click', - description: 'Click left mouse button', - inputSchema: zodToJsonSchema(clickSchema), - }, - handle: async (context, params) => { - return await runAndWait(context, 'Clicked mouse', async page => { - const validatedParams = clickSchema.parse(params); - await page.mouse.move(validatedParams.x, validatedParams.y); - await page.mouse.down(); - await page.mouse.up(); - }); - }, -}; - -const dragSchema = elementSchema.extend({ - startX: z.number().describe('Start X coordinate'), - startY: z.number().describe('Start Y coordinate'), - endX: z.number().describe('End X coordinate'), - endY: z.number().describe('End Y coordinate'), -}); - -export const drag: Tool = { - schema: { - name: 'browser_drag', - description: 'Drag left mouse button', - inputSchema: zodToJsonSchema(dragSchema), - }, - - handle: async (context, params) => { - const validatedParams = dragSchema.parse(params); - return await runAndWait(context, `Dragged mouse from (${validatedParams.startX}, ${validatedParams.startY}) to (${validatedParams.endX}, ${validatedParams.endY})`, async page => { - await page.mouse.move(validatedParams.startX, validatedParams.startY); - await page.mouse.down(); - await page.mouse.move(validatedParams.endX, validatedParams.endY); - await page.mouse.up(); - }); - }, -}; - -const typeSchema = z.object({ - text: z.string().describe('Text to type into the element'), - submit: z.boolean().describe('Whether to submit entered text (press Enter after)'), + return { + code, + action, + captureSnapshot: true, + waitForNetwork: false, + }; + } }); -export const type: Tool = { - schema: { - name: 'browser_type', - description: 'Type text', - inputSchema: zodToJsonSchema(typeSchema), - }, - - handle: async (context, params) => { - const validatedParams = typeSchema.parse(params); - return await runAndWait(context, `Typed text "${validatedParams.text}"`, async page => { - await page.keyboard.type(validatedParams.text); - if (validatedParams.submit) - await page.keyboard.press('Enter'); - }); - }, -}; +export default [ + screenshot, +]; diff --git a/src/tools/snapshot.ts b/src/tools/snapshot.ts index 4e75805..49aaf52 100644 --- a/src/tools/snapshot.ts +++ b/src/tools/snapshot.ts @@ -15,141 +15,212 @@ */ import { z } from 'zod'; -import zodToJsonSchema from 'zod-to-json-schema'; -import { captureAriaSnapshot, runAndWait } from './utils'; +import { defineTool } from './tool.js'; +import * as javascript from '../javascript.js'; +import { generateLocator } from './utils.js'; -import type * as playwright from 'playwright'; -import type { Tool } from './tool'; - -export const snapshot: Tool = { +const snapshot = defineTool({ + capability: 'core', schema: { name: 'browser_snapshot', + title: 'Page snapshot', description: 'Capture accessibility snapshot of the current page, this is better than screenshot', - inputSchema: zodToJsonSchema(z.object({})), + inputSchema: z.object({}), + type: 'readOnly', }, handle: async context => { - return await captureAriaSnapshot(context); + await context.ensureTab(); + + return { + code: [`// `], + captureSnapshot: true, + waitForNetwork: false, + }; }, -}; +}); const elementSchema = z.object({ element: z.string().describe('Human-readable element description used to obtain permission to interact with the element'), ref: z.string().describe('Exact target element reference from the page snapshot'), }); -export const click: Tool = { +const click = defineTool({ + capability: 'core', schema: { name: 'browser_click', + title: 'Click', description: 'Perform click on a web page', - inputSchema: zodToJsonSchema(elementSchema), + inputSchema: elementSchema, + type: 'destructive', }, handle: async (context, params) => { - const validatedParams = elementSchema.parse(params); - return runAndWait(context, `"${validatedParams.element}" clicked`, () => context.refLocator(validatedParams.ref).click(), true); - }, -}; + const tab = context.currentTabOrDie(); + const locator = tab.snapshotOrDie().refLocator(params); -const dragSchema = z.object({ - startElement: z.string().describe('Human-readable source element description used to obtain the permission to interact with the element'), - startRef: z.string().describe('Exact source element reference from the page snapshot'), - endElement: z.string().describe('Human-readable target element description used to obtain the permission to interact with the element'), - endRef: z.string().describe('Exact target element reference from the page snapshot'), + const code = [ + `// Click ${params.element}`, + `await page.${await generateLocator(locator)}.click();` + ]; + + return { + code, + action: () => locator.click(), + captureSnapshot: true, + waitForNetwork: true, + }; + }, }); -export const drag: Tool = { +const drag = defineTool({ + capability: 'core', schema: { name: 'browser_drag', + title: 'Drag mouse', description: 'Perform drag and drop between two elements', - inputSchema: zodToJsonSchema(dragSchema), + inputSchema: z.object({ + startElement: z.string().describe('Human-readable source element description used to obtain the permission to interact with the element'), + startRef: z.string().describe('Exact source element reference from the page snapshot'), + endElement: z.string().describe('Human-readable target element description used to obtain the permission to interact with the element'), + endRef: z.string().describe('Exact target element reference from the page snapshot'), + }), + type: 'destructive', }, handle: async (context, params) => { - const validatedParams = dragSchema.parse(params); - return runAndWait(context, `Dragged "${validatedParams.startElement}" to "${validatedParams.endElement}"`, async () => { - const startLocator = context.refLocator(validatedParams.startRef); - const endLocator = context.refLocator(validatedParams.endRef); - await startLocator.dragTo(endLocator); - }, true); + const snapshot = context.currentTabOrDie().snapshotOrDie(); + const startLocator = snapshot.refLocator({ ref: params.startRef, element: params.startElement }); + const endLocator = snapshot.refLocator({ ref: params.endRef, element: params.endElement }); + + const code = [ + `// Drag ${params.startElement} to ${params.endElement}`, + `await page.${await generateLocator(startLocator)}.dragTo(page.${await generateLocator(endLocator)});` + ]; + + return { + code, + action: () => startLocator.dragTo(endLocator), + captureSnapshot: true, + waitForNetwork: true, + }; }, -}; +}); -export const hover: Tool = { +const hover = defineTool({ + capability: 'core', schema: { name: 'browser_hover', + title: 'Hover mouse', description: 'Hover over element on page', - inputSchema: zodToJsonSchema(elementSchema), + inputSchema: elementSchema, + type: 'readOnly', }, handle: async (context, params) => { - const validatedParams = elementSchema.parse(params); - return runAndWait(context, `Hovered over "${validatedParams.element}"`, () => context.refLocator(validatedParams.ref).hover(), true); + const snapshot = context.currentTabOrDie().snapshotOrDie(); + const locator = snapshot.refLocator(params); + + const code = [ + `// Hover over ${params.element}`, + `await page.${await generateLocator(locator)}.hover();` + ]; + + return { + code, + action: () => locator.hover(), + captureSnapshot: true, + waitForNetwork: true, + }; }, -}; +}); const typeSchema = elementSchema.extend({ text: z.string().describe('Text to type into the element'), - submit: z.boolean().describe('Whether to submit entered text (press Enter after)'), + submit: z.boolean().optional().describe('Whether to submit entered text (press Enter after)'), + slowly: z.boolean().optional().describe('Whether to type one character at a time. Useful for triggering key handlers in the page. By default entire text is filled in at once.'), }); -export const type: Tool = { +const type = defineTool({ + capability: 'core', schema: { name: 'browser_type', + title: 'Type text', description: 'Type text into editable element', - inputSchema: zodToJsonSchema(typeSchema), + inputSchema: typeSchema, + type: 'destructive', }, handle: async (context, params) => { - const validatedParams = typeSchema.parse(params); - return await runAndWait(context, `Typed "${validatedParams.text}" into "${validatedParams.element}"`, async () => { - const locator = context.refLocator(validatedParams.ref); - await locator.fill(validatedParams.text); - if (validatedParams.submit) - await locator.press('Enter'); - }, true); + const snapshot = context.currentTabOrDie().snapshotOrDie(); + const locator = snapshot.refLocator(params); + + const code: string[] = []; + const steps: (() => Promise)[] = []; + + if (params.slowly) { + code.push(`// Press "${params.text}" sequentially into "${params.element}"`); + code.push(`await page.${await generateLocator(locator)}.pressSequentially(${javascript.quote(params.text)});`); + steps.push(() => locator.pressSequentially(params.text)); + } else { + code.push(`// Fill "${params.text}" into "${params.element}"`); + code.push(`await page.${await generateLocator(locator)}.fill(${javascript.quote(params.text)});`); + steps.push(() => locator.fill(params.text)); + } + + if (params.submit) { + code.push(`// Submit text`); + code.push(`await page.${await generateLocator(locator)}.press('Enter');`); + steps.push(() => locator.press('Enter')); + } + + return { + code, + action: () => steps.reduce((acc, step) => acc.then(step), Promise.resolve()), + captureSnapshot: true, + waitForNetwork: true, + }; }, -}; +}); const selectOptionSchema = elementSchema.extend({ values: z.array(z.string()).describe('Array of values to select in the dropdown. This can be a single value or multiple values.'), }); -export const selectOption: Tool = { +const selectOption = defineTool({ + capability: 'core', schema: { name: 'browser_select_option', + title: 'Select option', description: 'Select an option in a dropdown', - inputSchema: zodToJsonSchema(selectOptionSchema), + inputSchema: selectOptionSchema, + type: 'destructive', }, handle: async (context, params) => { - const validatedParams = selectOptionSchema.parse(params); - return await runAndWait(context, `Selected option in "${validatedParams.element}"`, async () => { - const locator = context.refLocator(validatedParams.ref); - await locator.selectOption(validatedParams.values); - }, true); - }, -}; - -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.'), -}); + const snapshot = context.currentTabOrDie().snapshotOrDie(); + const locator = snapshot.refLocator(params); -export const screenshot: Tool = { - 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.`, - inputSchema: zodToJsonSchema(screenshotSchema), - }, + const code = [ + `// Select options [${params.values.join(', ')}] in ${params.element}`, + `await page.${await generateLocator(locator)}.selectOption(${javascript.formatObject(params.values)});` + ]; - handle: async (context, params) => { - const validatedParams = screenshotSchema.parse(params); - const page = context.existingPage(); - const options: playwright.PageScreenshotOptions = validatedParams.raw ? { type: 'png', scale: 'css' } : { type: 'jpeg', quality: 50, scale: 'css' }; - const screenshot = await page.screenshot(options); return { - content: [{ type: 'image', data: screenshot.toString('base64'), mimeType: validatedParams.raw ? 'image/png' : 'image/jpeg' }], + code, + action: () => locator.selectOption(params.values).then(() => {}), + captureSnapshot: true, + waitForNetwork: true, }; }, -}; +}); + +export default [ + snapshot, + click, + drag, + hover, + type, + selectOption, +]; diff --git a/src/tools/tabs.ts b/src/tools/tabs.ts new file mode 100644 index 0000000..4133bf1 --- /dev/null +++ b/src/tools/tabs.ts @@ -0,0 +1,134 @@ +/** + * Copyright (c) Microsoft Corporation. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { z } from 'zod'; +import { defineTool, type ToolFactory } from './tool.js'; + +const listTabs = defineTool({ + capability: 'tabs', + + schema: { + name: 'browser_tab_list', + title: 'List tabs', + description: 'List browser tabs', + inputSchema: z.object({}), + type: 'readOnly', + }, + + handle: async context => { + await context.ensureTab(); + return { + code: [`// `], + captureSnapshot: false, + waitForNetwork: false, + resultOverride: { + content: [{ + type: 'text', + text: await context.listTabsMarkdown(), + }], + }, + }; + }, +}); + +const selectTab: ToolFactory = captureSnapshot => defineTool({ + capability: 'tabs', + + schema: { + name: 'browser_tab_select', + title: 'Select a tab', + description: 'Select a tab by index', + inputSchema: z.object({ + index: z.number().describe('The index of the tab to select'), + }), + type: 'readOnly', + }, + + handle: async (context, params) => { + await context.selectTab(params.index); + const code = [ + `// `, + ]; + + return { + code, + captureSnapshot, + waitForNetwork: false + }; + }, +}); + +const newTab: ToolFactory = captureSnapshot => defineTool({ + capability: 'tabs', + + schema: { + name: 'browser_tab_new', + title: 'Open a new tab', + description: 'Open a new tab', + inputSchema: z.object({ + url: z.string().optional().describe('The URL to navigate to in the new tab. If not provided, the new tab will be blank.'), + }), + type: 'readOnly', + }, + + handle: async (context, params) => { + await context.newTab(); + if (params.url) + await context.currentTabOrDie().navigate(params.url); + + const code = [ + `// `, + ]; + return { + code, + captureSnapshot, + waitForNetwork: false + }; + }, +}); + +const closeTab: ToolFactory = captureSnapshot => defineTool({ + capability: 'tabs', + + schema: { + name: 'browser_tab_close', + title: 'Close a tab', + description: 'Close a tab', + inputSchema: z.object({ + index: z.number().optional().describe('The index of the tab to close. Closes current tab if not provided.'), + }), + type: 'destructive', + }, + + handle: async (context, params) => { + await context.closeTab(params.index); + const code = [ + `// `, + ]; + return { + code, + captureSnapshot, + waitForNetwork: false + }; + }, +}); + +export default (captureSnapshot: boolean) => [ + listTabs, + newTab(captureSnapshot), + selectTab(captureSnapshot), + closeTab(captureSnapshot), +]; diff --git a/src/tools/testing.ts b/src/tools/testing.ts new file mode 100644 index 0000000..9518d19 --- /dev/null +++ b/src/tools/testing.ts @@ -0,0 +1,67 @@ +/** + * Copyright (c) Microsoft Corporation. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { z } from 'zod'; +import { defineTool } from './tool.js'; + +const generateTestSchema = z.object({ + name: z.string().describe('The name of the test'), + description: z.string().describe('The description of the test'), + steps: z.array(z.string()).describe('The steps of the test'), +}); + +const generateTest = defineTool({ + capability: 'testing', + + schema: { + name: 'browser_generate_playwright_test', + title: 'Generate a Playwright test', + description: 'Generate a Playwright test for given scenario', + inputSchema: generateTestSchema, + type: 'readOnly', + }, + + handle: async (context, params) => { + return { + resultOverride: { + content: [{ + type: 'text', + text: instructions(params), + }], + }, + code: [], + captureSnapshot: false, + waitForNetwork: false, + }; + }, +}); + +const instructions = (params: { name: string, description: string, steps: string[] }) => [ + `## Instructions`, + `- You are a playwright test generator.`, + `- You are given a scenario and you need to generate a playwright test for it.`, + '- DO NOT generate test code based on the scenario alone. DO run steps one by one using the tools provided instead.', + '- Only after all steps are completed, emit a Playwright TypeScript test that uses @playwright/test based on message history', + '- Save generated test file in the tests directory', + `Test name: ${params.name}`, + `Description: ${params.description}`, + `Steps:`, + ...params.steps.map((step, index) => `- ${index + 1}. ${step}`), +].join('\n'); + +export default [ + generateTest, +]; diff --git a/src/tools/tool.ts b/src/tools/tool.ts index 19f1b60..4b88c89 100644 --- a/src/tools/tool.ts +++ b/src/tools/tool.ts @@ -14,24 +14,55 @@ * limitations under the License. */ -import type { ImageContent, TextContent } from '@modelcontextprotocol/sdk/types'; -import type { JsonSchema7Type } from 'zod-to-json-schema'; -import type { Context } from '../context'; +import type { ImageContent, TextContent } from '@modelcontextprotocol/sdk/types.js'; +import type { z } from 'zod'; +import type { Context } from '../context.js'; +import type * as playwright from 'playwright'; +import type { ToolCapability } from '../../config.js'; -export type ToolSchema = { +export type ToolSchema = { name: string; + title: string; description: string; - inputSchema: JsonSchema7Type; + inputSchema: Input; + type: 'readOnly' | 'destructive'; }; +type InputType = z.Schema; + +export type FileUploadModalState = { + type: 'fileChooser'; + description: string; + fileChooser: playwright.FileChooser; +}; + +export type DialogModalState = { + type: 'dialog'; + description: string; + dialog: playwright.Dialog; +}; + +export type ModalState = FileUploadModalState | DialogModalState; + +export type ToolActionResult = { content?: (ImageContent | TextContent)[] } | undefined | void; + export type ToolResult = { - content: (ImageContent | TextContent)[]; - isError?: boolean; + code: string[]; + action?: () => Promise; + captureSnapshot: boolean; + waitForNetwork: boolean; + resultOverride?: ToolActionResult; }; -export type Tool = { - schema: ToolSchema; - handle: (context: Context, params?: Record) => Promise; +export type Tool = { + capability: ToolCapability; + schema: ToolSchema; + clearsModalState?: ModalState['type']; + handle: (context: Context, params: z.output) => Promise; }; -export type ToolFactory = (snapshot: boolean) => Tool; +export type ToolFactory = (snapshot: boolean) => Tool; + +export function defineTool(tool: Tool): Tool { + return tool; +} diff --git a/src/tools/utils.ts b/src/tools/utils.ts index 29b6dc9..497b038 100644 --- a/src/tools/utils.ts +++ b/src/tools/utils.ts @@ -15,10 +15,10 @@ */ import type * as playwright from 'playwright'; -import type { ToolResult } from './tool'; -import type { Context } from '../context'; +import type { Context } from '../context.js'; +import type { Tab } from '../tab.js'; -async function waitForCompletion(page: playwright.Page, callback: () => Promise): Promise { +export async function waitForCompletion(context: Context, tab: Tab, callback: () => Promise): Promise { const requests = new Set(); let frameNavigated = false; let waitCallback: () => void = () => {}; @@ -37,9 +37,7 @@ async function waitForCompletion(page: playwright.Page, callback: () => Promi frameNavigated = true; dispose(); clearTimeout(timeout); - void frame.waitForLoadState('load').then(() => { - waitCallback(); - }); + void tab.waitForLoadState('load').then(waitCallback); }; const onTimeout = () => { @@ -47,15 +45,15 @@ async function waitForCompletion(page: playwright.Page, callback: () => Promi waitCallback(); }; - page.on('request', requestListener); - page.on('requestfinished', requestFinishedListener); - page.on('framenavigated', frameNavigateListener); + tab.page.on('request', requestListener); + tab.page.on('requestfinished', requestFinishedListener); + tab.page.on('framenavigated', frameNavigateListener); const timeout = setTimeout(onTimeout, 10000); const dispose = () => { - page.off('request', requestListener); - page.off('requestfinished', requestFinishedListener); - page.off('framenavigated', frameNavigateListener); + tab.page.off('request', requestListener); + tab.page.off('requestfinished', requestFinishedListener); + tab.page.off('framenavigated', frameNavigateListener); clearTimeout(timeout); }; @@ -64,45 +62,25 @@ async function waitForCompletion(page: playwright.Page, callback: () => Promi if (!requests.size && !frameNavigated) waitCallback(); await waitBarrier; - await page.evaluate(() => new Promise(f => setTimeout(f, 1000))); + await context.waitForTimeout(1000); return result; } finally { dispose(); } } -export async function runAndWait(context: Context, status: string, callback: (page: playwright.Page) => Promise, snapshot: boolean = false): Promise { - const page = context.existingPage(); - const dismissFileChooser = context.hasFileChooser(); - await waitForCompletion(page, () => callback(page)); - if (dismissFileChooser) - context.clearFileChooser(); - const result: ToolResult = snapshot ? await captureAriaSnapshot(context, status) : { - content: [{ type: 'text', text: status }], - }; - return result; +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 captureAriaSnapshot(context: Context, status: string = ''): Promise { - const page = context.existingPage(); - const lines = []; - if (status) - lines.push(`${status}`); - lines.push( - '', - `- Page URL: ${page.url()}`, - `- Page Title: ${await page.title()}` - ); - if (context.hasFileChooser()) - lines.push(`- There is a file chooser visible that requires browser_choose_file to be called`); - lines.push( - `- Page Snapshot`, - '```yaml', - await context.allFramesSnapshot(), - '```', - '' - ); - return { - content: [{ type: 'text', text: lines.join('\n') }], - }; +export async function generateLocator(locator: playwright.Locator): Promise { + return (locator as any)._generateLocatorString(); +} + +export async function callOnPageNoTrace(page: playwright.Page, callback: (page: playwright.Page) => Promise): Promise { + return await (page as any)._wrapApiCall(() => callback(page), { internal: true }); } diff --git a/src/tools/vision.ts b/src/tools/vision.ts new file mode 100644 index 0000000..a380311 --- /dev/null +++ b/src/tools/vision.ts @@ -0,0 +1,213 @@ +/** + * Copyright (c) Microsoft Corporation. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { z } from 'zod'; +import { defineTool } from './tool.js'; + +import * as javascript from '../javascript.js'; + +const elementSchema = z.object({ + element: z.string().describe('Human-readable element description used to obtain permission to interact with the element'), +}); + +const screenshot = defineTool({ + capability: 'core', + schema: { + name: 'browser_screen_capture', + title: 'Take a screenshot', + description: 'Take a screenshot of the current page', + inputSchema: z.object({}), + type: 'readOnly', + }, + + handle: async context => { + const tab = await context.ensureTab(); + const options = { type: 'jpeg' as 'jpeg', quality: 50, scale: 'css' as 'css' }; + + const code = [ + `// Take a screenshot of the current page`, + `await page.screenshot(${javascript.formatObject(options)});`, + ]; + + const action = () => tab.page.screenshot(options).then(buffer => { + return { + content: [{ type: 'image' as 'image', data: buffer.toString('base64'), mimeType: 'image/jpeg' }], + }; + }); + + return { + code, + action, + captureSnapshot: false, + waitForNetwork: false + }; + }, +}); + +const moveMouse = defineTool({ + capability: 'core', + schema: { + name: 'browser_screen_move_mouse', + title: 'Move mouse', + description: 'Move mouse to a given position', + inputSchema: elementSchema.extend({ + x: z.number().describe('X coordinate'), + y: z.number().describe('Y coordinate'), + }), + type: 'readOnly', + }, + + handle: async (context, params) => { + const tab = context.currentTabOrDie(); + const code = [ + `// Move mouse to (${params.x}, ${params.y})`, + `await page.mouse.move(${params.x}, ${params.y});`, + ]; + const action = () => tab.page.mouse.move(params.x, params.y); + return { + code, + action, + captureSnapshot: false, + waitForNetwork: false + }; + }, +}); + +const click = defineTool({ + capability: 'core', + schema: { + name: 'browser_screen_click', + title: 'Click', + description: 'Click left mouse button', + inputSchema: elementSchema.extend({ + x: z.number().describe('X coordinate'), + y: z.number().describe('Y coordinate'), + }), + type: 'destructive', + }, + + handle: async (context, params) => { + const tab = context.currentTabOrDie(); + const code = [ + `// Click mouse at coordinates (${params.x}, ${params.y})`, + `await page.mouse.move(${params.x}, ${params.y});`, + `await page.mouse.down();`, + `await page.mouse.up();`, + ]; + const action = async () => { + await tab.page.mouse.move(params.x, params.y); + await tab.page.mouse.down(); + await tab.page.mouse.up(); + }; + return { + code, + action, + captureSnapshot: false, + waitForNetwork: true, + }; + }, +}); + +const drag = defineTool({ + capability: 'core', + schema: { + name: 'browser_screen_drag', + title: 'Drag mouse', + description: 'Drag left mouse button', + inputSchema: elementSchema.extend({ + startX: z.number().describe('Start X coordinate'), + startY: z.number().describe('Start Y coordinate'), + endX: z.number().describe('End X coordinate'), + endY: z.number().describe('End Y coordinate'), + }), + type: 'destructive', + }, + + handle: async (context, params) => { + const tab = context.currentTabOrDie(); + + const code = [ + `// Drag mouse from (${params.startX}, ${params.startY}) to (${params.endX}, ${params.endY})`, + `await page.mouse.move(${params.startX}, ${params.startY});`, + `await page.mouse.down();`, + `await page.mouse.move(${params.endX}, ${params.endY});`, + `await page.mouse.up();`, + ]; + + const action = async () => { + await tab.page.mouse.move(params.startX, params.startY); + await tab.page.mouse.down(); + await tab.page.mouse.move(params.endX, params.endY); + await tab.page.mouse.up(); + }; + + return { + code, + action, + captureSnapshot: false, + waitForNetwork: true, + }; + }, +}); + +const type = defineTool({ + capability: 'core', + schema: { + name: 'browser_screen_type', + title: 'Type text', + description: 'Type text', + inputSchema: z.object({ + text: z.string().describe('Text to type into the element'), + submit: z.boolean().optional().describe('Whether to submit entered text (press Enter after)'), + }), + type: 'destructive', + }, + + handle: async (context, params) => { + const tab = context.currentTabOrDie(); + + const code = [ + `// Type ${params.text}`, + `await page.keyboard.type('${params.text}');`, + ]; + + const action = async () => { + await tab.page.keyboard.type(params.text); + if (params.submit) + await tab.page.keyboard.press('Enter'); + }; + + if (params.submit) { + code.push(`// Submit text`); + code.push(`await page.keyboard.press('Enter');`); + } + + return { + code, + action, + captureSnapshot: false, + waitForNetwork: true, + }; + }, +}); + +export default [ + screenshot, + moveMouse, + click, + drag, + type, +]; diff --git a/src/tools/wait.ts b/src/tools/wait.ts new file mode 100644 index 0000000..fc8be82 --- /dev/null +++ b/src/tools/wait.ts @@ -0,0 +1,70 @@ +/** + * Copyright (c) Microsoft Corporation. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { z } from 'zod'; +import { defineTool, type ToolFactory } from './tool.js'; + +const wait: ToolFactory = captureSnapshot => defineTool({ + capability: 'wait', + + schema: { + name: 'browser_wait_for', + title: 'Wait for', + description: 'Wait for text to appear or disappear or a specified time to pass', + inputSchema: z.object({ + time: z.number().optional().describe('The time to wait in seconds'), + text: z.string().optional().describe('The text to wait for'), + textGone: z.string().optional().describe('The text to wait for to disappear'), + }), + type: 'readOnly', + }, + + handle: async (context, params) => { + if (!params.text && !params.textGone && !params.time) + throw new Error('Either time, text or textGone must be provided'); + + const code: string[] = []; + + if (params.time) { + code.push(`await new Promise(f => setTimeout(f, ${params.time!} * 1000));`); + await new Promise(f => setTimeout(f, Math.min(10000, params.time! * 1000))); + } + + const tab = context.currentTabOrDie(); + const locator = params.text ? tab.page.getByText(params.text).first() : undefined; + const goneLocator = params.textGone ? tab.page.getByText(params.textGone).first() : undefined; + + if (goneLocator) { + code.push(`await page.getByText(${JSON.stringify(params.textGone)}).first().waitFor({ state: 'hidden' });`); + await goneLocator.waitFor({ state: 'hidden' }); + } + + if (locator) { + code.push(`await page.getByText(${JSON.stringify(params.text)}).first().waitFor({ state: 'visible' });`); + await locator.waitFor({ state: 'visible' }); + } + + return { + code, + captureSnapshot, + waitForNetwork: false, + }; + }, +}); + +export default (captureSnapshot: boolean) => [ + wait(captureSnapshot), +]; diff --git a/src/transport.ts b/src/transport.ts new file mode 100644 index 0000000..14f6a8d --- /dev/null +++ b/src/transport.ts @@ -0,0 +1,138 @@ +/** + * 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 http from 'node:http'; +import assert from 'node:assert'; +import crypto from 'node:crypto'; + +import debug from 'debug'; +import { SSEServerTransport } from '@modelcontextprotocol/sdk/server/sse.js'; +import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js'; +import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'; + +import type { Server } from './server.js'; + +export async function startStdioTransport(server: Server) { + await server.createConnection(new StdioServerTransport()); +} + +const testDebug = debug('pw:mcp:test'); + +async function handleSSE(server: Server, req: http.IncomingMessage, res: http.ServerResponse, url: URL, sessions: Map) { + if (req.method === 'POST') { + const sessionId = url.searchParams.get('sessionId'); + if (!sessionId) { + res.statusCode = 400; + return res.end('Missing sessionId'); + } + + const transport = sessions.get(sessionId); + if (!transport) { + res.statusCode = 404; + return res.end('Session not found'); + } + + return await transport.handlePostMessage(req, res); + } else if (req.method === 'GET') { + const transport = new SSEServerTransport('/sse', res); + sessions.set(transport.sessionId, transport); + testDebug(`create SSE session: ${transport.sessionId}`); + const connection = await server.createConnection(transport); + res.on('close', () => { + testDebug(`delete SSE session: ${transport.sessionId}`); + sessions.delete(transport.sessionId); + // eslint-disable-next-line no-console + void connection.close().catch(e => console.error(e)); + }); + return; + } + + res.statusCode = 405; + res.end('Method not allowed'); +} + +async function handleStreamable(server: Server, req: http.IncomingMessage, res: http.ServerResponse, sessions: Map) { + const sessionId = req.headers['mcp-session-id'] as string | undefined; + if (sessionId) { + const transport = sessions.get(sessionId); + if (!transport) { + res.statusCode = 404; + res.end('Session not found'); + return; + } + return await transport.handleRequest(req, res); + } + + if (req.method === 'POST') { + const transport = new StreamableHTTPServerTransport({ + sessionIdGenerator: () => crypto.randomUUID(), + onsessioninitialized: sessionId => { + sessions.set(sessionId, transport); + } + }); + transport.onclose = () => { + if (transport.sessionId) + sessions.delete(transport.sessionId); + }; + await server.createConnection(transport); + await transport.handleRequest(req, res); + return; + } + + res.statusCode = 400; + res.end('Invalid request'); +} + +export function startHttpTransport(server: Server) { + const sseSessions = new Map(); + const streamableSessions = new Map(); + const httpServer = http.createServer(async (req, res) => { + const url = new URL(`http://localhost${req.url}`); + if (url.pathname.startsWith('/mcp')) + await handleStreamable(server, req, res, streamableSessions); + else + await handleSSE(server, req, res, url, sseSessions); + }); + const { host, port } = server.config.server; + httpServer.listen(port, host, () => { + const address = httpServer.address(); + assert(address, 'Could not bind server socket'); + let url: string; + if (typeof address === 'string') { + url = address; + } else { + const resolvedPort = address.port; + let resolvedHost = address.family === 'IPv4' ? address.address : `[${address.address}]`; + if (resolvedHost === '0.0.0.0' || resolvedHost === '[::]') + resolvedHost = 'localhost'; + url = `http://${resolvedHost}:${resolvedPort}`; + } + const message = [ + `Listening on ${url}`, + 'Put this in your client config:', + JSON.stringify({ + 'mcpServers': { + 'playwright': { + 'url': `${url}/sse` + } + } + }, undefined, 2), + 'If your client supports streamable HTTP, you can use the /mcp endpoint instead.', + ].join('\n'); + // eslint-disable-next-line no-console + console.error(message); + }); +} diff --git a/tests/agent.spec.ts b/tests/agent.spec.ts new file mode 100644 index 0000000..ce2a8ee --- /dev/null +++ b/tests/agent.spec.ts @@ -0,0 +1,77 @@ +/** + * 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 path from 'path'; +import url from 'node:url'; + +import { spawn } from 'child_process'; +import { test as baseTest, expect } from './fixtures.js'; + +import type { ChildProcess } from 'child_process'; + +const __filename = url.fileURLToPath(import.meta.url); + +const test = baseTest.extend<{ agentEndpoint: (options?: { args?: string[] }) => Promise<{ url: URL, stdout: () => string }> }>({ + agentEndpoint: async ({}, use) => { + let cp: ChildProcess | undefined; + await use(async (options?: { args?: string[] }) => { + if (cp) + throw new Error('Process already running'); + + cp = spawn('node', [ + path.join(path.dirname(__filename), '../lib/browserAgent.js'), + ...(options?.args || []), + ], { + stdio: 'pipe', + env: { + ...process.env, + DEBUG: 'pw:mcp:test', + DEBUG_COLORS: '0', + DEBUG_HIDE_DATE: '1', + }, + }); + 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]); + })); + + return { url: new URL(url), stdout: () => stdout }; + }); + cp?.kill('SIGTERM'); + }, +}); + +test.skip(({ mcpBrowser }) => mcpBrowser !== 'chrome', 'Agent is CDP-only for now'); + +test('browser lifecycle', async ({ agentEndpoint, startClient, server }) => { + const { url: agentUrl } = await agentEndpoint(); + const { client: client1 } = await startClient({ args: ['--browser-agent', agentUrl.toString()] }); + expect(await client1.callTool({ + name: 'browser_navigate', + arguments: { url: server.HELLO_WORLD }, + })).toContainTextContent('Hello, world!'); + + const { client: client2 } = await startClient({ args: ['--browser-agent', agentUrl.toString()] }); + expect(await client2.callTool({ + name: 'browser_navigate', + arguments: { url: server.HELLO_WORLD }, + })).toContainTextContent('Hello, world!'); + + await client1.close(); + await client2.close(); +}); diff --git a/tests/basic.spec.ts b/tests/basic.spec.ts deleted file mode 100644 index ee9ecc7..0000000 --- a/tests/basic.spec.ts +++ /dev/null @@ -1,333 +0,0 @@ -/** - * Copyright (c) Microsoft Corporation. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import 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(tools.map(t => t.name)).toEqual([ - 'browser_navigate', - 'browser_go_back', - 'browser_go_forward', - 'browser_choose_file', - 'browser_snapshot', - 'browser_click', - 'browser_hover', - 'browser_type', - 'browser_select_option', - 'browser_take_screenshot', - 'browser_press_key', - 'browser_wait', - 'browser_save_as_pdf', - 'browser_close', - ]); - - const { tools: visionTools } = await visionClient.listTools(); - expect(visionTools.map(t => t.name)).toEqual([ - 'browser_navigate', - 'browser_go_back', - 'browser_go_forward', - 'browser_choose_file', - 'browser_screenshot', - 'browser_move_mouse', - 'browser_click', - 'browser_drag', - 'browser_type', - 'browser_press_key', - 'browser_wait', - 'browser_save_as_pdf', - 'browser_close', - ]); -}); - -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 }) => { - expect(await client.callTool({ - name: 'browser_navigate', - arguments: { - url: 'data:text/html,TitleHello, world!', - }, - })).toHaveTextContent(` -- Page URL: data:text/html,TitleHello, world! -- Page Title: Title -- Page Snapshot -\`\`\`yaml -- document [ref=s1e2]: Hello, world! -\`\`\` -` - ); -}); - -test('test browser_click', async ({ client }) => { - await client.callTool({ - name: 'browser_navigate', - arguments: { - url: 'data:text/html,Title', - }, - }); - - expect(await client.callTool({ - name: 'browser_click', - arguments: { - element: 'Submit button', - ref: 's1e4', - }, - })).toHaveTextContent(`"Submit button" clicked - -- Page URL: data:text/html,Title -- Page Title: Title -- Page Snapshot -\`\`\`yaml -- document [ref=s2e2]: - - button "Submit" [ref=s2e4] -\`\`\` -`); -}); - -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(` -- Page URL: data:text/html,TitleHello, world! -- Page Title: Title -- Page Snapshot -\`\`\`yaml -- document [ref=s1e2]: Hello, world! -\`\`\` -`); -}); - -test('single option', async ({ client }) => { - await client.callTool({ - name: 'browser_navigate', - arguments: { - url: 'data:text/html,Title', - }, - }); - - expect(await client.callTool({ - name: 'browser_select_option', - arguments: { - element: 'Select', - ref: 's1e4', - values: ['bar'], - }, - })).toHaveTextContent(`Selected option in "Select" - -- Page URL: data:text/html,Title -- Page Title: Title -- Page Snapshot -\`\`\`yaml -- document [ref=s2e2]: - - combobox [ref=s2e4]: - - option "Foo" [ref=s2e5] - - option "Bar" [selected] [ref=s2e6] -\`\`\` -`); -}); - -test('multiple option', async ({ client }) => { - await client.callTool({ - name: 'browser_navigate', - arguments: { - url: 'data:text/html,Title', - }, - }); - - expect(await client.callTool({ - name: 'browser_select_option', - arguments: { - element: 'Select', - ref: 's1e4', - values: ['bar', 'baz'], - }, - })).toHaveTextContent(`Selected option in "Select" - -- Page URL: data:text/html,Title -- Page Title: Title -- Page Snapshot -\`\`\`yaml -- document [ref=s2e2]: - - listbox [ref=s2e4]: - - option "Foo" [ref=s2e5] - - option "Bar" [selected] [ref=s2e6] - - option "Baz" [selected] [ref=s2e7] -\`\`\` -`); -}); - -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

', - }, - })).toHaveTextContent(` -- Page URL: data:text/html,

Hello

-- Page Title: -- Page Snapshot -\`\`\`yaml -- document [ref=s1e2]: - - heading "Hello" [level=1] [ref=s1e4] - -# iframe src=data:text/html,

World

-- document [ref=f0s1e2]: - - heading "World" [level=1] [ref=f0s1e4] -\`\`\` -`); -}); - -test('browser_choose_file', async ({ client }) => { - expect(await client.callTool({ - name: 'browser_navigate', - arguments: { - url: 'data:text/html,Title', - }, - })).toContainTextContent('- textbox [ref=s1e4]'); - - expect(await client.callTool({ - name: 'browser_click', - arguments: { - element: 'Textbox', - ref: 's1e4', - }, - })).toContainTextContent('There is a file chooser visible that requires browser_choose_file 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', - arguments: { - paths: [filePath], - }, - }); - - expect(response).not.toContainTextContent('There is a file chooser visible that requires browser_choose_file to be called'); - expect(response).toContainTextContent('textbox [ref=s3e4]: C:\\fakepath\\test.txt'); - } - - { - const response = await client.callTool({ - name: 'browser_click', - arguments: { - element: 'Textbox', - ref: 's3e4', - }, - }); - - expect(response).toContainTextContent('There is a file chooser visible that requires browser_choose_file to be called'); - expect(response).toContainTextContent('button "Button" [ref=s4e5]'); - } - - { - const response = await client.callTool({ - name: 'browser_click', - arguments: { - element: 'Button', - ref: 's4e5', - }, - }); - - 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'); - } -}); - -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(` -- Page URL: data:text/html,TitleHello, world! -- Page Title: Title -- Page Snapshot -\`\`\`yaml -- document [ref=s1e2]: Hello, world! -\`\`\` -` - ); -}); diff --git a/tests/capabilities.spec.ts b/tests/capabilities.spec.ts new file mode 100644 index 0000000..83af532 --- /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.js'; + +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_console_messages', + 'browser_drag', + 'browser_file_upload', + 'browser_generate_playwright_test', + 'browser_handle_dialog', + 'browser_hover', + 'browser_select_option', + 'browser_type', + 'browser_close', + 'browser_install', + 'browser_navigate_back', + 'browser_navigate_forward', + 'browser_navigate', + 'browser_network_requests', + 'browser_pdf_save', + 'browser_press_key', + 'browser_resize', + 'browser_snapshot', + 'browser_tab_close', + 'browser_tab_list', + 'browser_tab_new', + 'browser_tab_select', + 'browser_take_screenshot', + 'browser_wait_for', + ])); +}); + +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_console_messages', + 'browser_file_upload', + 'browser_generate_playwright_test', + 'browser_handle_dialog', + 'browser_install', + 'browser_navigate_back', + 'browser_navigate_forward', + 'browser_navigate', + 'browser_network_requests', + 'browser_pdf_save', + 'browser_press_key', + 'browser_resize', + '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_for', + ])); +}); + +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..7a3492b --- /dev/null +++ b/tests/cdp.spec.ts @@ -0,0 +1,77 @@ +/** + * 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('cdp server', async ({ cdpServer, startClient, server }) => { + await cdpServer.start(); + const { client } = await startClient({ args: [`--cdp-endpoint=${cdpServer.endpoint}`] }); + expect(await client.callTool({ + name: 'browser_navigate', + arguments: { url: server.HELLO_WORLD }, + })).toContainTextContent(`- generic [ref=e1]: Hello, world!`); +}); + +test('cdp server reuse tab', async ({ cdpServer, startClient, server }) => { + const browserContext = await cdpServer.start(); + const { client } = await startClient({ args: [`--cdp-endpoint=${cdpServer.endpoint}`] }); + + const [page] = browserContext.pages(); + await page.goto(server.HELLO_WORLD); + + expect(await client.callTool({ + name: 'browser_click', + arguments: { + element: 'Hello, world!', + ref: 'f0', + }, + })).toHaveTextContent(`Error: No current snapshot available. Capture a snapshot or navigate to a new location first.`); + + expect(await client.callTool({ + name: 'browser_snapshot', + })).toHaveTextContent(` +- Ran Playwright code: +\`\`\`js +// +\`\`\` + +- Page URL: ${server.HELLO_WORLD} +- Page Title: Title +- Page Snapshot +\`\`\`yaml +- generic [ref=e1]: Hello, world! +\`\`\` +`); +}); + +test('should throw connection error and allow re-connecting', async ({ cdpServer, startClient, server }) => { + const { client } = await startClient({ args: [`--cdp-endpoint=${cdpServer.endpoint}`] }); + + server.setContent('/', ` + Title + Hello, world! + `, 'text/html'); + + expect(await client.callTool({ + name: 'browser_navigate', + arguments: { url: server.PREFIX }, + })).toContainTextContent(`Error: browserType.connectOverCDP: connect ECONNREFUSED`); + await cdpServer.start(); + expect(await client.callTool({ + name: 'browser_navigate', + arguments: { url: server.PREFIX }, + })).toContainTextContent(`- generic [ref=e1]: Hello, world!`); +}); diff --git a/tests/config.spec.ts b/tests/config.spec.ts new file mode 100644 index 0000000..4478347 --- /dev/null +++ b/tests/config.spec.ts @@ -0,0 +1,63 @@ +/** + * 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 'node:fs'; + +import { Config } from '../config.js'; +import { test, expect } from './fixtures.js'; + +test('config user data dir', async ({ startClient, server }, testInfo) => { + server.setContent('/', ` + Title + Hello, world! + `, 'text/html'); + + const config: Config = { + browser: { + userDataDir: testInfo.outputPath('user-data-dir'), + }, + }; + const configPath = testInfo.outputPath('config.json'); + await fs.promises.writeFile(configPath, JSON.stringify(config, null, 2)); + + const { client } = await startClient({ args: ['--config', configPath] }); + expect(await client.callTool({ + name: 'browser_navigate', + arguments: { url: server.PREFIX }, + })).toContainTextContent(`Hello, world!`); + + const files = await fs.promises.readdir(config.browser!.userDataDir!); + expect(files.length).toBeGreaterThan(0); +}); + +test.describe(() => { + test.use({ mcpBrowser: '' }); + test('browserName', { annotation: { type: 'issue', description: 'https://github.com/microsoft/playwright-mcp/issues/458' } }, async ({ startClient }, testInfo) => { + const config: Config = { + browser: { + browserName: 'firefox', + }, + }; + const configPath = testInfo.outputPath('config.json'); + await fs.promises.writeFile(configPath, JSON.stringify(config, null, 2)); + + const { client } = await startClient({ args: ['--config', configPath] }); + expect(await client.callTool({ + name: 'browser_navigate', + arguments: { url: 'data:text/html,' }, + })).toContainTextContent(`Firefox`); + }); +}); diff --git a/tests/console.spec.ts b/tests/console.spec.ts new file mode 100644 index 0000000..8a9f60a --- /dev/null +++ b/tests/console.spec.ts @@ -0,0 +1,44 @@ +/** + * Copyright (c) Microsoft Corporation. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { test, expect } from './fixtures.js'; + +test('browser_console_messages', async ({ client, server }) => { + server.setContent('/', ` + + + + + `, 'text/html'); + + await client.callTool({ + name: 'browser_navigate', + arguments: { + url: server.PREFIX, + }, + }); + + const resource = await client.callTool({ + name: 'browser_console_messages', + }); + expect(resource).toHaveTextContent([ + '[LOG] Hello, world!', + '[ERROR] Error', + ].join('\n')); +}); diff --git a/tests/core.spec.ts b/tests/core.spec.ts new file mode 100644 index 0000000..003ea98 --- /dev/null +++ b/tests/core.spec.ts @@ -0,0 +1,239 @@ +/** + * Copyright (c) Microsoft Corporation. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { test, expect } from './fixtures.js'; + +test('browser_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 URL: ${server.HELLO_WORLD} +- Page Title: Title +- Page Snapshot +\`\`\`yaml +- generic [ref=e1]: Hello, world! +\`\`\` +` + ); +}); + +test('browser_click', async ({ client, server }) => { + server.setContent('/', ` + Title + + `, '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', + }, + })).toHaveTextContent(` +- Ran Playwright code: +\`\`\`js +// Click Submit button +await page.getByRole('button', { name: 'Submit' }).click(); +\`\`\` + +- Page URL: ${server.PREFIX} +- Page Title: Title +- Page Snapshot +\`\`\`yaml +- button "Submit" [ref=e2] +\`\`\` +`); +}); + +test('browser_select_option', async ({ client, server }) => { + server.setContent('/', ` + Title + + `, 'text/html'); + + await client.callTool({ + name: 'browser_navigate', + arguments: { url: server.PREFIX }, + }); + + expect(await client.callTool({ + name: 'browser_select_option', + arguments: { + element: 'Select', + ref: 'e2', + values: ['bar'], + }, + })).toHaveTextContent(` +- Ran Playwright code: +\`\`\`js +// Select options [bar] in Select +await page.getByRole('combobox').selectOption(['bar']); +\`\`\` + +- 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 }) => { + server.setContent('/', ` + Title + + `, 'text/html'); + + await client.callTool({ + name: 'browser_navigate', + arguments: { url: server.PREFIX }, + }); + + expect(await client.callTool({ + name: 'browser_select_option', + arguments: { + element: 'Select', + ref: 'e2', + values: ['bar', 'baz'], + }, + })).toHaveTextContent(` +- Ran Playwright code: +\`\`\`js +// Select options [bar, baz] in Select +await page.getByRole('listbox').selectOption(['bar', 'baz']); +\`\`\` + +- Page URL: ${server.PREFIX} +- Page Title: Title +- Page Snapshot +\`\`\`yaml +- listbox [ref=e2]: + - option "Foo" [ref=e3] + - option "Bar" [selected] [ref=e4] + - option "Baz" [selected] [ref=e5] +\`\`\` +`); +}); + +test('browser_type', async ({ client, server }) => { + server.setContent('/', ` + + + + + `, 'text/html'); + + await client.callTool({ + name: 'browser_navigate', + arguments: { + url: server.PREFIX, + }, + }); + await client.callTool({ + name: 'browser_type', + arguments: { + element: 'textbox', + ref: 'e2', + text: 'Hi!', + submit: true, + }, + }); + expect(await client.callTool({ + name: 'browser_console_messages', + })).toHaveTextContent('[LOG] Key pressed: Enter , Text: Hi!'); +}); + +test('browser_type (slowly)', async ({ client, server }) => { + server.setContent('/', ` + + `, 'text/html'); + + await client.callTool({ + name: 'browser_navigate', + arguments: { + url: server.PREFIX, + }, + }); + await client.callTool({ + name: 'browser_type', + arguments: { + element: 'textbox', + ref: 'e2', + text: 'Hi!', + submit: true, + slowly: true, + }, + }); + expect(await client.callTool({ + name: 'browser_console_messages', + })).toHaveTextContent([ + '[LOG] Key pressed: H Text: ', + '[LOG] Key pressed: i Text: H', + '[LOG] Key pressed: ! Text: Hi', + '[LOG] Key pressed: Enter Text: Hi!', + ].join('\n')); +}); + +test('browser_resize', async ({ client, server }) => { + server.setContent('/', ` + Resize Test + +
Waiting for resize...
+ + + `, 'text/html'); + await client.callTool({ + name: 'browser_navigate', + arguments: { url: server.PREFIX }, + }); + + const response = await client.callTool({ + name: 'browser_resize', + arguments: { + width: 390, + 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'); +}); diff --git a/tests/device.spec.ts b/tests/device.spec.ts new file mode 100644 index 0000000..32ceecb --- /dev/null +++ b/tests/device.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.js'; + +test('--device should work', async ({ startClient, server }) => { + const { client } = await startClient({ + args: ['--device', 'iPhone 15'], + }); + + server.route('/', (req, res) => { + res.writeHead(200, { 'Content-Type': 'text/html' }); + res.end(` + + + + + + `); + }); + + expect(await client.callTool({ + name: 'browser_navigate', + arguments: { + url: server.PREFIX, + }, + })).toContainTextContent(`393x659`); +}); diff --git a/tests/dialogs.spec.ts b/tests/dialogs.spec.ts new file mode 100644 index 0000000..b70fe2d --- /dev/null +++ b/tests/dialogs.spec.ts @@ -0,0 +1,212 @@ +/** + * 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'; + +// https://github.com/microsoft/playwright/issues/35663 +test.skip(({ mcpBrowser, mcpHeadless }) => mcpBrowser === 'webkit' && mcpHeadless); + +test('alert dialog', async ({ client, server }) => { + server.setContent('/', ``, 'text/html'); + expect(await client.callTool({ + name: 'browser_navigate', + arguments: { url: server.PREFIX }, + })).toContainTextContent('- button "Button" [ref=e2]'); + + expect(await client.callTool({ + name: 'browser_click', + arguments: { + 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`); + + const result = await client.callTool({ + name: 'browser_handle_dialog', + arguments: { + accept: true, + }, + }); + + expect(result).not.toContainTextContent('### Modal state'); + expect(result).toHaveTextContent(`- Ran Playwright code: +\`\`\`js +// +\`\`\` + +- Page URL: ${server.PREFIX} +- Page Title: +- Page Snapshot +\`\`\`yaml +- button "Button" [ref=e2] +\`\`\` +`); +}); + +test('two alert dialogs', async ({ client, server }) => { + test.fixme(true, 'Race between the dialog and ariaSnapshot'); + + server.setContent('/', ` + Title + + + + `, 'text/html'); + + expect(await client.callTool({ + name: 'browser_navigate', + arguments: { url: server.PREFIX }, + })).toContainTextContent('- button "Button" [ref=e2]'); + + expect(await client.callTool({ + name: 'browser_click', + arguments: { + 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`); + + const result = await client.callTool({ + name: 'browser_handle_dialog', + arguments: { + accept: true, + }, + }); + + expect(result).not.toContainTextContent('### Modal state'); +}); + +test('confirm dialog (true)', async ({ client, server }) => { + server.setContent('/', ` + Title + + + + `, 'text/html'); + + expect(await client.callTool({ + name: 'browser_navigate', + arguments: { url: server.PREFIX }, + })).toContainTextContent('- button "Button" [ref=e2]'); + + expect(await client.callTool({ + name: 'browser_click', + arguments: { + element: 'Button', + ref: 'e2', + }, + })).toContainTextContent(`### Modal state +- ["confirm" dialog with message "Confirm"]: can be handled by the "browser_handle_dialog" tool`); + + const result = await client.callTool({ + name: 'browser_handle_dialog', + arguments: { + accept: true, + }, + }); + + expect(result).not.toContainTextContent('### Modal state'); + expect(result).toContainTextContent('// '); + expect(result).toContainTextContent(`- Page Snapshot +\`\`\`yaml +- generic [ref=e1]: "true" +\`\`\``); +}); + +test('confirm dialog (false)', async ({ client, server }) => { + server.setContent('/', ` + Title + + + + `, 'text/html'); + + expect(await client.callTool({ + name: 'browser_navigate', + arguments: { url: server.PREFIX }, + })).toContainTextContent('- button "Button" [ref=e2]'); + + expect(await client.callTool({ + name: 'browser_click', + arguments: { + element: 'Button', + ref: 'e2', + }, + })).toContainTextContent(`### Modal state +- ["confirm" dialog with message "Confirm"]: can be handled by the "browser_handle_dialog" tool`); + + const result = await client.callTool({ + name: 'browser_handle_dialog', + arguments: { + accept: false, + }, + }); + + expect(result).toContainTextContent(`- Page Snapshot +\`\`\`yaml +- generic [ref=e1]: "false" +\`\`\``); +}); + +test('prompt dialog', async ({ client, server }) => { + server.setContent('/', ` + Title + + + + `, 'text/html'); + + expect(await client.callTool({ + name: 'browser_navigate', + arguments: { url: server.PREFIX }, + })).toContainTextContent('- button "Button" [ref=e2]'); + + expect(await client.callTool({ + name: 'browser_click', + arguments: { + element: 'Button', + ref: 'e2', + }, + })).toContainTextContent(`### Modal state +- ["prompt" dialog with message "Prompt"]: can be handled by the "browser_handle_dialog" tool`); + + const result = await client.callTool({ + name: 'browser_handle_dialog', + arguments: { + accept: true, + promptText: 'Answer', + }, + }); + + expect(result).toContainTextContent(`- Page Snapshot +\`\`\`yaml +- generic [ref=e1]: Answer +\`\`\``); +}); diff --git a/tests/files.spec.ts b/tests/files.spec.ts new file mode 100644 index 0000000..3653bca --- /dev/null +++ b/tests/files.spec.ts @@ -0,0 +1,147 @@ +/** + * 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'; +import fs from 'fs/promises'; + +test('browser_file_upload', async ({ client, server }, testInfo) => { + server.setContent('/', ` + + + `, 'text/html'); + + expect(await client.callTool({ + name: 'browser_navigate', + arguments: { url: server.PREFIX }, + })).toContainTextContent(` +\`\`\`yaml +- generic [ref=e1]: + - button "Choose File" [ref=e2] + - button "Button" [ref=e3] +\`\`\``); + + { + expect(await client.callTool({ + name: 'browser_file_upload', + arguments: { paths: [] }, + })).toHaveTextContent(` +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()); + } + + expect(await client.callTool({ + name: 'browser_click', + arguments: { + element: 'Textbox', + ref: 'e2', + }, + })).toContainTextContent(`### Modal state +- [File chooser]: can be handled by the "browser_file_upload" tool`); + + const filePath = testInfo.outputPath('test.txt'); + await fs.writeFile(filePath, 'Hello, world!'); + + { + const response = await client.callTool({ + name: 'browser_file_upload', + arguments: { + paths: [filePath], + }, + }); + + expect(response).not.toContainTextContent('### Modal state'); + expect(response).toContainTextContent(` +\`\`\`yaml +- generic [ref=e1]: + - button "Choose File" [ref=e2] + - button "Button" [ref=e3] +\`\`\``); + } + + { + const response = await client.callTool({ + name: 'browser_click', + arguments: { + element: 'Textbox', + ref: 'e2', + }, + }); + + expect(response).toContainTextContent('- [File chooser]: can be handled by the \"browser_file_upload\" tool'); + } + + { + const response = await client.callTool({ + name: 'browser_click', + arguments: { + element: 'Button', + ref: 'e3', + }, + }); + + expect(response).toContainTextContent(`Tool "browser_click" does not handle the modal state. +### Modal state +- [File chooser]: can be handled by the "browser_file_upload" tool`); + } +}); + +test('clicking on download link emits download', async ({ startClient, server }, testInfo) => { + const { client } = await startClient({ + config: { outputDir: testInfo.outputPath('output') }, + }); + + server.setContent('/', `Download`, 'text/html'); + server.setContent('/download', 'Data', 'text/plain'); + + expect(await client.callTool({ + name: 'browser_navigate', + arguments: { url: server.PREFIX }, + })).toContainTextContent('- link "Download" [ref=e2]'); + await client.callTool({ + name: 'browser_click', + arguments: { + element: 'Download link', + ref: 'e2', + }, + }); + await expect.poll(() => client.callTool({ name: 'browser_snapshot' })).toContainTextContent(` +### Downloads +- Downloaded file test.txt to ${testInfo.outputPath('output', 'test.txt')}`); +}); + +test('navigating to download link emits download', async ({ startClient, server, mcpBrowser }, testInfo) => { + const { client } = await startClient({ + config: { outputDir: testInfo.outputPath('output') }, + }); + + test.skip(mcpBrowser === 'webkit' && process.platform === 'linux', 'https://github.com/microsoft/playwright/blob/8e08fdb52c27bb75de9bf87627bf740fadab2122/tests/library/download.spec.ts#L436'); + server.route('/download', (req, res) => { + res.writeHead(200, { + 'Content-Type': 'text/plain', + 'Content-Disposition': 'attachment; filename=test.txt', + }); + res.end('Hello world!'); + }); + + expect(await client.callTool({ + name: 'browser_navigate', + arguments: { + url: server.PREFIX + 'download', + }, + })).toContainTextContent('### Downloads'); +}); diff --git a/tests/fixtures.ts b/tests/fixtures.ts index e805f31..5c6c166 100644 --- a/tests/fixtures.ts +++ b/tests/fixtures.ts @@ -14,49 +14,88 @@ * limitations under the License. */ +import fs from 'fs'; +import url from 'url'; import path from 'path'; 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 { TestServer } from './testserver/index.ts'; -type Fixtures = { +import type { Config } from '../config'; +import type { BrowserContext } from 'playwright'; + +export type TestOptions = { + mcpBrowser: string | undefined; + mcpMode: 'docker' | undefined; +}; + +type CDPServer = { + endpoint: string; + start: () => Promise; +}; + +type TestFixtures = { client: Client; visionClient: Client; - startClient: (options?: { args?: string[], vision?: boolean }) => Promise; + startClient: (options?: { clientName?: string, args?: string[], config?: Config }) => Promise<{ client: Client, stderr: () => string }>; wsEndpoint: string; - cdpEndpoint: string; + cdpServer: CDPServer; + server: TestServer; + httpsServer: TestServer; + mcpHeadless: boolean; }; -export const test = baseTest.extend({ +type WorkerFixtures = { + _workerServers: { server: TestServer, httpsServer: TestServer }; +}; + +export const test = baseTest.extend({ client: async ({ startClient }, use) => { - await use(await startClient()); + const { client } = await startClient(); + await use(client); }, visionClient: async ({ startClient }, use) => { - await use(await startClient({ vision: true })); + const { client } = await startClient({ args: ['--vision'] }); + await use(client); }, - startClient: async ({ }, use, testInfo) => { - const userDataDir = testInfo.outputPath('user-data-dir'); - let client: StdioClientTransport | undefined; + 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; - use(async options => { - const args = ['--headless', '--user-data-dir', userDataDir]; - if (options?.vision) - args.push('--vision'); + 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) + args.push('--headless'); + if (mcpBrowser) + args.push(`--browser=${mcpBrowser}`); if (options?.args) args.push(...options.args); - const transport = new StdioClientTransport({ - command: 'node', - args: [path.join(__dirname, '../cli.js'), ...args], + if (options?.config) { + const configFile = testInfo.outputPath('config.json'); + await fs.promises.writeFile(configFile, JSON.stringify(options.config, null, 2)); + args.push(`--config=${path.relative(configDir, configFile)}`); + } + + client = new Client({ name: options?.clientName ?? 'test', version: '1.0.0' }); + const transport = createTransport(args, mcpMode); + let stderr = ''; + transport.stderr?.on('data', data => { + stderr += data.toString(); }); - const client = new Client({ name: 'test', version: '1.0.0' }); await client.connect(transport); await client.ping(); - return client; + return { client, stderr: () => stderr }; }); await client?.close(); @@ -68,28 +107,103 @@ export const test = baseTest.extend({ await browserServer.close(); }, - cdpEndpoint: async ({ }, use, testInfo) => { - const port = 3200 + (+process.env.TEST_PARALLEL_INDEX!); - const browser = await chromium.launchPersistentContext(testInfo.outputPath('user-data-dir'), { - args: [`--remote-debugging-port=${port}`], + cdpServer: async ({ mcpBrowser }, use, testInfo) => { + test.skip(!['chrome', 'msedge', 'chromium'].includes(mcpBrowser!), 'CDP is not supported for non-Chromium browsers'); + + let browserContext: BrowserContext | undefined; + const port = 3200 + test.info().parallelIndex; + await use({ + endpoint: `http://localhost:${port}`, + start: async () => { + browserContext = await chromium.launchPersistentContext(testInfo.outputPath('cdp-user-data-dir'), { + channel: mcpBrowser, + headless: true, + args: [ + `--remote-debugging-port=${port}`, + ], + }); + return browserContext; + } }); - await use(`http://localhost:${port}`); - await browser.close(); + await browserContext?.close(); + }, + + mcpHeadless: async ({ headless }, use) => { + await use(headless); + }, + + mcpBrowser: ['chrome', { option: true }], + + mcpMode: [undefined, { option: true }], + + _workerServers: [async ({}, use, workerInfo) => { + const port = 8907 + workerInfo.workerIndex * 4; + const server = await TestServer.create(port); + + const httpsPort = port + 1; + const httpsServer = await TestServer.createHTTPS(httpsPort); + + await use({ server, httpsServer }); + + await Promise.all([ + server.stop(), + httpsServer.stop(), + ]); + }, { scope: 'worker' }], + + server: async ({ _workerServers }, use) => { + _workerServers.server.reset(); + await use(_workerServers.server); + }, + + httpsServer: async ({ _workerServers }, use) => { + _workerServers.httpsServer.reset(); + await use(_workerServers.httpsServer); }, }); +function createTransport(args: string[], mcpMode: TestOptions['mcpMode']) { + // NOTE: Can be removed when we drop Node.js 18 support and changed to import.meta.filename. + const __filename = url.fileURLToPath(import.meta.url); + if (mcpMode === 'docker') { + const dockerArgs = ['run', '--rm', '-i', '--network=host', '-v', `${test.info().project.outputDir}:/app/test-results`]; + return new StdioClientTransport({ + command: 'docker', + args: [...dockerArgs, 'playwright-mcp-dev:latest', ...args], + }); + } + return new StdioClientTransport({ + command: 'node', + args: [path.join(path.dirname(__filename), '../cli.js'), ...args], + cwd: path.join(path.dirname(__filename), '..'), + stderr: 'pipe', + env: { + ...process.env, + DEBUG: 'pw:mcp:test', + DEBUG_COLORS: '0', + DEBUG_HIDE_DATE: '1', + }, + }); +} + type Response = Awaited>; export const expect = baseExpect.extend({ - toHaveTextContent(response: Response, content: string | string[]) { + toHaveTextContent(response: Response, content: string | RegExp) { const isNot = this.isNot; try { - content = Array.isArray(content) ? content : [content]; - const texts = (response.content as any).map(c => c.text); - if (isNot) - baseExpect(texts).not.toEqual(content); - else - baseExpect(texts).toEqual(content); + 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, @@ -125,3 +239,7 @@ 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); +} diff --git a/tests/headed.spec.ts b/tests/headed.spec.ts new file mode 100644 index 0000000..69202c4 --- /dev/null +++ b/tests/headed.spec.ts @@ -0,0 +1,50 @@ +/** + * 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'; + +for (const mcpHeadless of [false, true]) { + test.describe(`mcpHeadless: ${mcpHeadless}`, () => { + 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) => { + res.writeHead(200, { 'Content-Type': 'text/html' }); + res.end(` + + + `); + }); + + const response = await client.callTool({ + name: 'browser_navigate', + arguments: { + url: server.PREFIX, + }, + }); + + expect(response).toContainTextContent(`Mozilla/5.0`); + if (mcpHeadless) + expect(response).toContainTextContent(`HeadlessChrome`); + else + expect(response).not.toContainTextContent(`HeadlessChrome`); + }); + }); +} diff --git a/tests/iframes.spec.ts b/tests/iframes.spec.ts new file mode 100644 index 0000000..f856270 --- /dev/null +++ b/tests/iframes.spec.ts @@ -0,0 +1,44 @@ +/** + * 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('stitched aria frames', async ({ client }) => { + expect(await client.callTool({ + name: 'browser_navigate', + arguments: { + url: `data:text/html,

Hello

`, + }, + })).toContainTextContent(` +\`\`\`yaml +- generic [ref=e1]: + - heading "Hello" [level=1] [ref=e2] + - iframe [ref=e3]: + - generic [ref=f1e1]: + - button "World" [ref=f1e2] + - main [ref=f1e3]: + - iframe [ref=f1e4]: + - paragraph [ref=f2e2]: Nested +\`\`\``); + + expect(await client.callTool({ + name: 'browser_click', + arguments: { + element: 'World', + ref: 'f1e2', + }, + })).toContainTextContent(`// Click World`); +}); diff --git a/tests/install.spec.ts b/tests/install.spec.ts new file mode 100644 index 0000000..66a11d5 --- /dev/null +++ b/tests/install.spec.ts @@ -0,0 +1,24 @@ +/** + * Copyright (c) Microsoft Corporation. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { test, expect } from './fixtures.js'; + +test('browser_install', async ({ client, mcpBrowser }) => { + test.skip(mcpBrowser !== 'chromium', 'Test only chromium'); + expect(await client.callTool({ + name: 'browser_install', + })).toContainTextContent(`No open pages available.`); +}); diff --git a/tests/launch.spec.ts b/tests/launch.spec.ts new file mode 100644 index 0000000..f0ad4b2 --- /dev/null +++ b/tests/launch.spec.ts @@ -0,0 +1,157 @@ +/** + * 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 { test, expect, formatOutput } from './fixtures.js'; + +test('test reopen browser', async ({ startClient, server }) => { + const { client, stderr } = await startClient(); + await client.callTool({ + name: 'browser_navigate', + arguments: { url: server.HELLO_WORLD }, + }); + + expect(await client.callTool({ + name: 'browser_close', + })).toContainTextContent('No open pages available'); + + expect(await client.callTool({ + name: 'browser_navigate', + arguments: { url: server.HELLO_WORLD }, + })).toContainTextContent(`- generic [ref=e1]: Hello, world!`); + + await client.close(); + + if (process.platform === 'win32') + return; + + await expect.poll(() => formatOutput(stderr()), { timeout: 0 }).toEqual([ + 'create context', + 'create browser context (persistent)', + 'lock user data dir', + 'close context', + 'close browser context (persistent)', + 'release user data dir', + 'close browser context complete (persistent)', + 'create browser context (persistent)', + 'lock user data dir', + 'close context', + 'close browser context (persistent)', + 'release user data dir', + 'close browser context complete (persistent)', + ]); +}); + +test('executable path', async ({ startClient, server }) => { + const { client } = await startClient({ args: [`--executable-path=bogus`] }); + const response = await client.callTool({ + name: 'browser_navigate', + arguments: { url: server.HELLO_WORLD }, + }); + expect(response).toContainTextContent(`executable doesn't exist`); +}); + +test('persistent context', async ({ startClient, server }) => { + server.setContent('/', ` + + + + `, 'text/html'); + + const { client } = await startClient(); + const response = await client.callTool({ + name: 'browser_navigate', + arguments: { url: server.PREFIX }, + }); + expect(response).toContainTextContent(`Storage: NO`); + + await new Promise(resolve => setTimeout(resolve, 3000)); + + await client.callTool({ + name: 'browser_close', + }); + + const { client: client2 } = await startClient(); + const response2 = await client2.callTool({ + name: 'browser_navigate', + arguments: { url: server.PREFIX }, + }); + + expect(response2).toContainTextContent(`Storage: YES`); +}); + +test('isolated context', async ({ startClient, server }) => { + server.setContent('/', ` + + + + `, 'text/html'); + + const { client: client1 } = await startClient({ args: [`--isolated`] }); + const response = await client1.callTool({ + name: 'browser_navigate', + arguments: { url: server.PREFIX }, + }); + expect(response).toContainTextContent(`Storage: NO`); + + await client1.callTool({ + name: 'browser_close', + }); + + const { client: client2 } = await startClient({ args: [`--isolated`] }); + const response2 = await client2.callTool({ + name: 'browser_navigate', + arguments: { url: server.PREFIX }, + }); + expect(response2).toContainTextContent(`Storage: NO`); +}); + +test('isolated context with storage state', async ({ startClient, server }, testInfo) => { + const storageStatePath = testInfo.outputPath('storage-state.json'); + await fs.promises.writeFile(storageStatePath, JSON.stringify({ + origins: [ + { + origin: server.PREFIX, + localStorage: [{ name: 'test', value: 'session-value' }], + }, + ], + })); + + server.setContent('/', ` + + + + `, 'text/html'); + + const { client } = await startClient({ args: [ + `--isolated`, + `--storage-state=${storageStatePath}`, + ] }); + const response = await client.callTool({ + name: 'browser_navigate', + arguments: { url: server.PREFIX }, + }); + expect(response).toContainTextContent(`Storage: session-value`); +}); diff --git a/tests/library.spec.ts b/tests/library.spec.ts new file mode 100644 index 0000000..954403f --- /dev/null +++ b/tests/library.spec.ts @@ -0,0 +1,28 @@ +/** + * 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'; +import fs from 'node:fs/promises'; +import child_process from 'node:child_process'; + +test('library can be used from CommonJS', { annotation: { type: 'issue', description: 'https://github.com/microsoft/playwright-mcp/issues/456' } }, async ({}, testInfo) => { + const file = testInfo.outputPath('main.cjs'); + await fs.writeFile(file, ` + import('@playwright/mcp') + .then(playwrightMCP => playwrightMCP.createConnection()) + .then(() => console.log('OK')); + `); + expect(child_process.execSync(`node ${file}`, { encoding: 'utf-8' })).toContain('OK'); +}); diff --git a/tests/network.spec.ts b/tests/network.spec.ts new file mode 100644 index 0000000..56e71c0 --- /dev/null +++ b/tests/network.spec.ts @@ -0,0 +1,45 @@ +/** + * Copyright (c) Microsoft Corporation. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { test, expect } from './fixtures.js'; + +test('browser_network_requests', async ({ client, server }) => { + server.setContent('/', ` + + `, 'text/html'); + + server.setContent('/json', JSON.stringify({ name: 'John Doe' }), 'application/json'); + + await client.callTool({ + name: 'browser_navigate', + arguments: { + url: server.PREFIX, + }, + }); + + await client.callTool({ + name: 'browser_click', + arguments: { + element: 'Click me button', + ref: 'e2', + }, + }); + + await expect.poll(() => client.callTool({ + name: 'browser_network_requests', + })).toHaveTextContent(`[GET] ${`${server.PREFIX}`} => [200] OK +[GET] ${`${server.PREFIX}json`} => [200] OK`); +}); diff --git a/tests/pdf.spec.ts b/tests/pdf.spec.ts new file mode 100644 index 0000000..8af4667 --- /dev/null +++ b/tests/pdf.spec.ts @@ -0,0 +1,83 @@ +/** + * 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 { test, expect } from './fixtures.js'; + +test('save as pdf unavailable', async ({ startClient, server }) => { + const { client } = await startClient({ args: ['--caps="no-pdf"'] }); + await client.callTool({ + name: 'browser_navigate', + arguments: { url: server.HELLO_WORLD }, + }); + + expect(await client.callTool({ + name: 'browser_pdf_save', + })).toHaveTextContent(/Tool \"browser_pdf_save\" not found/); +}); + +test('save as pdf', async ({ startClient, mcpBrowser, server }, testInfo) => { + const { client } = await startClient({ + config: { outputDir: testInfo.outputPath('output') }, + }); + + test.skip(!!mcpBrowser && !['chromium', 'chrome', 'msedge'].includes(mcpBrowser), 'Save as PDF is only supported in Chromium.'); + + expect(await client.callTool({ + name: 'browser_navigate', + arguments: { url: server.HELLO_WORLD }, + })).toContainTextContent(`- generic [ref=e1]: Hello, world!`); + + const response = await client.callTool({ + name: 'browser_pdf_save', + }); + expect(response).toHaveTextContent(/Save page as.*page-[^:]+.pdf/); +}); + +test('save as pdf (filename: output.pdf)', async ({ startClient, mcpBrowser, server }, testInfo) => { + const outputDir = testInfo.outputPath('output'); + test.skip(!!mcpBrowser && !['chromium', 'chrome', 'msedge'].includes(mcpBrowser), 'Save as PDF is only supported in Chromium.'); + const { client } = await startClient({ + config: { outputDir }, + }); + + expect(await client.callTool({ + name: 'browser_navigate', + arguments: { url: server.HELLO_WORLD }, + })).toContainTextContent(`- generic [ref=e1]: Hello, world!`); + + expect(await client.callTool({ + name: 'browser_pdf_save', + arguments: { + filename: 'output.pdf', + }, + })).toEqual({ + content: [ + { + type: 'text', + text: expect.stringContaining(`output.pdf`), + }, + ], + }); + + const files = [...fs.readdirSync(outputDir)]; + + expect(fs.existsSync(outputDir)).toBeTruthy(); + const pdfFiles = files.filter(f => f.endsWith('.pdf')); + expect(pdfFiles).toHaveLength(1); + expect(pdfFiles[0]).toMatch(/^output.pdf$/); +}); diff --git a/tests/request-blocking.spec.ts b/tests/request-blocking.spec.ts new file mode 100644 index 0000000..21a7185 --- /dev/null +++ b/tests/request-blocking.spec.ts @@ -0,0 +1,82 @@ +/** + * Copyright (c) Microsoft Corporation. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { Client } from '@modelcontextprotocol/sdk/client/index.js'; +import { test, expect } from './fixtures.ts'; + +const BLOCK_MESSAGE = /Blocked by Web Inspector|NS_ERROR_FAILURE|net::ERR_BLOCKED_BY_CLIENT/g; + +const fetchPage = async (client: Client, url: string) => { + const result = await client.callTool({ + name: 'browser_navigate', + arguments: { + url, + }, + }); + + return JSON.stringify(result, null, 2); +}; + +test('default to allow all', async ({ server, client }) => { + server.setContent('/ppp', 'content:PPP', 'text/html'); + const result = await fetchPage(client, server.PREFIX + 'ppp'); + expect(result).toContain('content:PPP'); +}); + +test('blocked works', async ({ startClient }) => { + const { client } = await startClient({ + args: ['--blocked-origins', 'microsoft.com;example.com;playwright.dev'] + }); + const result = await fetchPage(client, 'https://example.com/'); + expect(result).toMatch(BLOCK_MESSAGE); +}); + +test('allowed works', async ({ server, startClient }) => { + server.setContent('/ppp', 'content:PPP', 'text/html'); + const { client } = await startClient({ + args: ['--allowed-origins', `microsoft.com;${new URL(server.PREFIX).host};playwright.dev`] + }); + const result = await fetchPage(client, server.PREFIX + 'ppp'); + expect(result).toContain('content:PPP'); +}); + +test('blocked takes precedence', async ({ startClient }) => { + const { client } = await startClient({ + args: [ + '--blocked-origins', 'example.com', + '--allowed-origins', 'example.com', + ], + }); + const result = await fetchPage(client, 'https://example.com/'); + expect(result).toMatch(BLOCK_MESSAGE); +}); + +test('allowed without blocked blocks all non-explicitly specified origins', async ({ startClient }) => { + const { client } = await startClient({ + args: ['--allowed-origins', 'playwright.dev'], + }); + const result = await fetchPage(client, 'https://example.com/'); + expect(result).toMatch(BLOCK_MESSAGE); +}); + +test('blocked without allowed allows non-explicitly specified origins', async ({ server, startClient }) => { + server.setContent('/ppp', 'content:PPP', 'text/html'); + const { client } = await startClient({ + args: ['--blocked-origins', 'example.com'], + }); + const result = await fetchPage(client, server.PREFIX + 'ppp'); + expect(result).toContain('content:PPP'); +}); diff --git a/tests/screenshot.spec.ts b/tests/screenshot.spec.ts new file mode 100644 index 0000000..b83e10a --- /dev/null +++ b/tests/screenshot.spec.ts @@ -0,0 +1,232 @@ +/** + * 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 { test, expect } from './fixtures.js'; + +test('browser_take_screenshot (viewport)', async ({ startClient, server }, testInfo) => { + const { client } = await startClient({ + config: { outputDir: testInfo.outputPath('output') }, + }); + expect(await client.callTool({ + name: 'browser_navigate', + arguments: { url: server.HELLO_WORLD }, + })).toContainTextContent(`Navigate to http://localhost`); + + expect(await client.callTool({ + name: 'browser_take_screenshot', + })).toEqual({ + content: [ + { + data: expect.any(String), + mimeType: 'image/jpeg', + type: 'image', + }, + { + text: expect.stringContaining(`Screenshot viewport and save it as`), + type: 'text', + }, + ], + }); +}); + +test('browser_take_screenshot (element)', async ({ startClient, server }, testInfo) => { + const { client } = await startClient({ + config: { outputDir: testInfo.outputPath('output') }, + }); + expect(await client.callTool({ + name: 'browser_navigate', + arguments: { url: server.HELLO_WORLD }, + })).toContainTextContent(`[ref=e1]`); + + expect(await client.callTool({ + name: 'browser_take_screenshot', + arguments: { + element: 'hello button', + ref: 'e1', + }, + })).toEqual({ + content: [ + { + data: expect.any(String), + mimeType: 'image/jpeg', + type: 'image', + }, + { + text: expect.stringContaining(`page.getByText('Hello, world!').screenshot`), + type: 'text', + }, + ], + }); +}); + +test('--output-dir should work', 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.HELLO_WORLD }, + })).toContainTextContent(`Navigate to http://localhost`); + + await client.callTool({ + name: 'browser_take_screenshot', + }); + + expect(fs.existsSync(outputDir)).toBeTruthy(); + const files = [...fs.readdirSync(outputDir)].filter(f => f.endsWith('.jpeg')); + 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$/); +}); + +for (const raw of [undefined, true]) { + test(`browser_take_screenshot (raw: ${raw})`, 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`); + + expect(await client.callTool({ + name: 'browser_take_screenshot', + arguments: { raw }, + })).toEqual({ + content: [ + { + data: expect.any(String), + mimeType: `image/${ext}`, + type: 'image', + }, + { + text: expect.stringMatching( + new RegExp(`page-\\d{4}-\\d{2}-\\d{2}T\\d{2}-\\d{2}-\\d{2}\\-\\d{3}Z\\.${ext}`) + ), + type: 'text', + }, + ], + }); + + const files = [...fs.readdirSync(outputDir)].filter(f => f.endsWith(`.${ext}`)); + + 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}$`) + ); + }); + +} + +test('browser_take_screenshot (filename: "output.jpeg")', 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.HELLO_WORLD }, + })).toContainTextContent(`Navigate to http://localhost`); + + expect(await client.callTool({ + name: 'browser_take_screenshot', + arguments: { + filename: 'output.jpeg', + }, + })).toEqual({ + content: [ + { + data: expect.any(String), + mimeType: 'image/jpeg', + type: 'image', + }, + { + text: expect.stringContaining(`output.jpeg`), + type: 'text', + }, + ], + }); + + const files = [...fs.readdirSync(outputDir)].filter(f => f.endsWith('.jpeg')); + + expect(fs.existsSync(outputDir)).toBeTruthy(); + expect(files).toHaveLength(1); + expect(files[0]).toMatch(/^output\.jpeg$/); +}); + +test('browser_take_screenshot (imageResponses=omit)', async ({ startClient, server }, testInfo) => { + const outputDir = testInfo.outputPath('output'); + const { client } = await startClient({ + config: { + outputDir, + imageResponses: 'omit', + }, + }); + + expect(await client.callTool({ + name: 'browser_navigate', + arguments: { url: server.HELLO_WORLD }, + })).toContainTextContent(`Navigate to http://localhost`); + + await client.callTool({ + name: 'browser_take_screenshot', + }); + + expect(await client.callTool({ + name: 'browser_take_screenshot', + })).toEqual({ + content: [ + { + text: expect.stringContaining(`Screenshot viewport and save it as`), + type: 'text', + }, + ], + }); +}); + +test('browser_take_screenshot (cursor)', async ({ startClient, server }, testInfo) => { + const outputDir = testInfo.outputPath('output'); + + const { client } = await startClient({ + clientName: 'cursor:vscode', + config: { outputDir }, + }); + + expect(await client.callTool({ + name: 'browser_navigate', + arguments: { url: server.HELLO_WORLD }, + })).toContainTextContent(`Navigate to http://localhost`); + + await client.callTool({ + name: 'browser_take_screenshot', + }); + + expect(await client.callTool({ + name: 'browser_take_screenshot', + })).toEqual({ + content: [ + { + text: expect.stringContaining(`Screenshot viewport and save it as`), + type: 'text', + }, + ], + }); +}); diff --git a/tests/sse.spec.ts b/tests/sse.spec.ts new file mode 100644 index 0000000..9e888a8 --- /dev/null +++ b/tests/sse.spec.ts @@ -0,0 +1,246 @@ +/** + * 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 'node:fs'; +import url from 'node:url'; + +import { ChildProcess, spawn } from 'node:child_process'; +import path from 'node:path'; +import { SSEClientTransport } from '@modelcontextprotocol/sdk/client/sse.js'; +import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js'; +import { Client } from '@modelcontextprotocol/sdk/client/index.js'; + +import { test as baseTest, expect } from './fixtures.js'; +import type { Config } from '../config.d.ts'; + +// NOTE: Can be removed when we drop Node.js 18 support and changed to import.meta.filename. +const __filename = url.fileURLToPath(import.meta.url); + +const test = baseTest.extend<{ serverEndpoint: (options?: { args?: string[], noPort?: boolean }) => Promise<{ url: URL, stderr: () => string }> }>({ + serverEndpoint: async ({ mcpHeadless }, use, testInfo) => { + let cp: ChildProcess | undefined; + const userDataDir = testInfo.outputPath('user-data-dir'); + await use(async (options?: { args?: string[], noPort?: boolean }) => { + if (cp) + throw new Error('Process already running'); + + cp = spawn('node', [ + path.join(path.dirname(__filename), '../cli.js'), + ...(options?.noPort ? [] : ['--port=0']), + '--user-data-dir=' + userDataDir, + ...(mcpHeadless ? ['--headless'] : []), + ...(options?.args || []), + ], { + stdio: 'pipe', + env: { + ...process.env, + DEBUG: 'pw:mcp:test', + DEBUG_COLORS: '0', + DEBUG_HIDE_DATE: '1', + }, + }); + let stderr = ''; + const url = await new Promise(resolve => cp!.stderr?.on('data', data => { + stderr += data.toString(); + const match = stderr.match(/Listening on (http:\/\/.*)/); + if (match) + resolve(match[1]); + })); + + return { url: new URL(url), stderr: () => stderr }; + }); + cp?.kill('SIGTERM'); + }, +}); + +test('sse transport', async ({ serverEndpoint }) => { + const { url } = await serverEndpoint(); + const transport = new SSEClientTransport(url); + const client = new Client({ name: 'test', version: '1.0.0' }); + await client.connect(transport); + await client.ping(); +}); + +test('sse transport (config)', async ({ serverEndpoint }) => { + const config: Config = { + server: { + port: 0, + } + }; + const configFile = test.info().outputPath('config.json'); + await fs.promises.writeFile(configFile, JSON.stringify(config, null, 2)); + + const { url } = await serverEndpoint({ noPort: true, args: ['--config=' + configFile] }); + const transport = new SSEClientTransport(url); + const client = new Client({ name: 'test', version: '1.0.0' }); + await client.connect(transport); + await client.ping(); +}); + +test('sse transport browser lifecycle (isolated)', async ({ serverEndpoint, server }) => { + const { url, stderr } = await serverEndpoint({ args: ['--isolated'] }); + + const transport1 = new SSEClientTransport(url); + const client1 = new Client({ name: 'test', version: '1.0.0' }); + await client1.connect(transport1); + await client1.callTool({ + name: 'browser_navigate', + arguments: { url: server.HELLO_WORLD }, + }); + await client1.close(); + + const transport2 = new SSEClientTransport(url); + const client2 = new Client({ name: 'test', version: '1.0.0' }); + await client2.connect(transport2); + await client2.callTool({ + name: 'browser_navigate', + arguments: { url: server.HELLO_WORLD }, + }); + await client2.close(); + + await expect(async () => { + const lines = stderr().split('\n'); + expect(lines.filter(line => line.match(/create SSE session/)).length).toBe(2); + expect(lines.filter(line => line.match(/delete SSE session/)).length).toBe(2); + + expect(lines.filter(line => line.match(/create context/)).length).toBe(2); + expect(lines.filter(line => line.match(/close context/)).length).toBe(2); + + expect(lines.filter(line => line.match(/create browser context \(isolated\)/)).length).toBe(2); + expect(lines.filter(line => line.match(/close browser context \(isolated\)/)).length).toBe(2); + + expect(lines.filter(line => line.match(/obtain browser \(isolated\)/)).length).toBe(2); + expect(lines.filter(line => line.match(/close browser \(isolated\)/)).length).toBe(2); + }).toPass(); +}); + +test('sse transport browser lifecycle (isolated, multiclient)', async ({ serverEndpoint, server }) => { + const { url, stderr } = await serverEndpoint({ args: ['--isolated'] }); + + const transport1 = new SSEClientTransport(url); + const client1 = new Client({ name: 'test', version: '1.0.0' }); + await client1.connect(transport1); + await client1.callTool({ + name: 'browser_navigate', + arguments: { url: server.HELLO_WORLD }, + }); + + const transport2 = new SSEClientTransport(url); + const client2 = new Client({ name: 'test', version: '1.0.0' }); + await client2.connect(transport2); + await client2.callTool({ + name: 'browser_navigate', + arguments: { url: server.HELLO_WORLD }, + }); + await client1.close(); + + const transport3 = new SSEClientTransport(url); + const client3 = new Client({ name: 'test', version: '1.0.0' }); + await client3.connect(transport3); + await client3.callTool({ + name: 'browser_navigate', + arguments: { url: server.HELLO_WORLD }, + }); + + await client2.close(); + await client3.close(); + + await expect(async () => { + const lines = stderr().split('\n'); + expect(lines.filter(line => line.match(/create SSE session/)).length).toBe(3); + expect(lines.filter(line => line.match(/delete SSE session/)).length).toBe(3); + + expect(lines.filter(line => line.match(/create context/)).length).toBe(3); + expect(lines.filter(line => line.match(/close context/)).length).toBe(3); + + expect(lines.filter(line => line.match(/create browser context \(isolated\)/)).length).toBe(3); + expect(lines.filter(line => line.match(/close browser context \(isolated\)/)).length).toBe(3); + + expect(lines.filter(line => line.match(/obtain browser \(isolated\)/)).length).toBe(1); + expect(lines.filter(line => line.match(/close browser \(isolated\)/)).length).toBe(1); + }).toPass(); +}); + +test('sse transport browser lifecycle (persistent)', async ({ serverEndpoint, server }) => { + const { url, stderr } = await serverEndpoint(); + + const transport1 = new SSEClientTransport(url); + const client1 = new Client({ name: 'test', version: '1.0.0' }); + await client1.connect(transport1); + await client1.callTool({ + name: 'browser_navigate', + arguments: { url: server.HELLO_WORLD }, + }); + await client1.close(); + + const transport2 = new SSEClientTransport(url); + const client2 = new Client({ name: 'test', version: '1.0.0' }); + await client2.connect(transport2); + await client2.callTool({ + name: 'browser_navigate', + arguments: { url: server.HELLO_WORLD }, + }); + await client2.close(); + + await expect(async () => { + const lines = stderr().split('\n'); + expect(lines.filter(line => line.match(/create SSE session/)).length).toBe(2); + expect(lines.filter(line => line.match(/delete SSE session/)).length).toBe(2); + + expect(lines.filter(line => line.match(/create context/)).length).toBe(2); + expect(lines.filter(line => line.match(/close context/)).length).toBe(2); + + expect(lines.filter(line => line.match(/create browser context \(persistent\)/)).length).toBe(2); + expect(lines.filter(line => line.match(/close browser context \(persistent\)/)).length).toBe(2); + + expect(lines.filter(line => line.match(/lock user data dir/)).length).toBe(2); + expect(lines.filter(line => line.match(/release user data dir/)).length).toBe(2); + }).toPass(); +}); + +test('sse transport browser lifecycle (persistent, multiclient)', async ({ serverEndpoint, server }) => { + const { url } = await serverEndpoint(); + + const transport1 = new SSEClientTransport(url); + const client1 = new Client({ name: 'test', version: '1.0.0' }); + await client1.connect(transport1); + await client1.callTool({ + name: 'browser_navigate', + arguments: { url: server.HELLO_WORLD }, + }); + + const transport2 = new SSEClientTransport(url); + const client2 = new Client({ name: 'test', version: '1.0.0' }); + await client2.connect(transport2); + const response = await client2.callTool({ + name: 'browser_navigate', + arguments: { url: server.HELLO_WORLD }, + }); + expect(response.isError).toBe(true); + expect(response.content?.[0].text).toContain('use --isolated to run multiple instances of the same browser'); + + await client1.close(); + await client2.close(); +}); + +test('streamable http transport', async ({ serverEndpoint }) => { + const { url } = await serverEndpoint(); + const transport = new StreamableHTTPClientTransport(new URL('/mcp', url)); + const client = new Client({ name: 'test', version: '1.0.0' }); + await client.connect(transport); + await client.ping(); + expect(transport.sessionId, 'has session support').toBeDefined(); +}); diff --git a/tests/tabs.spec.ts b/tests/tabs.spec.ts new file mode 100644 index 0000000..08afd63 --- /dev/null +++ b/tests/tabs.spec.ts @@ -0,0 +1,152 @@ +/** + * 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'; + +import type { Client } from '@modelcontextprotocol/sdk/client/index.js'; + +async function createTab(client: Client, title: string, body: string) { + return await client.callTool({ + name: 'browser_tab_new', + arguments: { + url: `data:text/html,${title}${body}`, + }, + }); +} + +test('list initial tabs', async ({ client }) => { + expect(await client.callTool({ + name: 'browser_tab_list', + })).toHaveTextContent(`### Open tabs +- 1: (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 +- 1: [] (about:blank) +- 2: (current) [Tab one] (data:text/html,Tab oneBody one)`); +}); + +test('create new tab', async ({ client }) => { + expect(await createTab(client, 'Tab one', 'Body one')).toHaveTextContent(` +- Ran Playwright code: +\`\`\`js +// +\`\`\` + +### Open tabs +- 1: [] (about:blank) +- 2: (current) [Tab one] (data:text/html,Tab oneBody one) + +### Current tab +- Page URL: data:text/html,Tab oneBody one +- Page Title: Tab one +- Page Snapshot +\`\`\`yaml +- generic [ref=e1]: Body one +\`\`\``); + + expect(await createTab(client, 'Tab two', 'Body two')).toHaveTextContent(` +- Ran Playwright code: +\`\`\`js +// +\`\`\` + +### Open tabs +- 1: [] (about:blank) +- 2: [Tab one] (data:text/html,Tab oneBody one) +- 3: (current) [Tab two] (data:text/html,Tab twoBody two) + +### Current tab +- Page URL: data:text/html,Tab twoBody two +- Page Title: Tab two +- Page Snapshot +\`\`\`yaml +- generic [ref=e1]: Body two +\`\`\``); +}); + +test('select tab', async ({ client }) => { + await createTab(client, 'Tab one', 'Body one'); + await createTab(client, 'Tab two', 'Body two'); + expect(await client.callTool({ + name: 'browser_tab_select', + arguments: { + index: 2, + }, + })).toHaveTextContent(` +- Ran Playwright code: +\`\`\`js +// +\`\`\` + +### Open tabs +- 1: [] (about:blank) +- 2: (current) [Tab one] (data:text/html,Tab oneBody one) +- 3: [Tab two] (data:text/html,Tab twoBody two) + +### Current tab +- Page URL: data:text/html,Tab oneBody one +- Page Title: Tab one +- Page Snapshot +\`\`\`yaml +- generic [ref=e1]: Body one +\`\`\``); +}); + +test('close tab', async ({ client }) => { + await createTab(client, 'Tab one', 'Body one'); + await createTab(client, 'Tab two', 'Body two'); + expect(await client.callTool({ + name: 'browser_tab_close', + arguments: { + index: 3, + }, + })).toHaveTextContent(` +- Ran Playwright code: +\`\`\`js +// +\`\`\` + +### Open tabs +- 1: [] (about:blank) +- 2: (current) [Tab one] (data:text/html,Tab oneBody one) + +### Current tab +- Page URL: data:text/html,Tab oneBody one +- Page Title: Tab one +- Page Snapshot +\`\`\`yaml +- generic [ref=e1]: Body one +\`\`\``); +}); + +test('reuse first tab when navigating', async ({ startClient, cdpServer, server }) => { + const browserContext = await cdpServer.start(); + const pages = browserContext.pages(); + + const { client } = await startClient({ args: [`--cdp-endpoint=${cdpServer.endpoint}`] }); + await client.callTool({ + name: 'browser_navigate', + arguments: { url: server.HELLO_WORLD }, + }); + + expect(pages.length).toBe(1); + expect(await pages[0].title()).toBe('Title'); +}); diff --git a/tests/testserver/cert.pem b/tests/testserver/cert.pem new file mode 100644 index 0000000..3388ed5 --- /dev/null +++ b/tests/testserver/cert.pem @@ -0,0 +1,29 @@ +-----BEGIN CERTIFICATE----- +MIIFCjCCAvKgAwIBAgIULU/gkDm8IqC7PG8u3RID0AYyP6gwDQYJKoZIhvcNAQEL +BQAwGjEYMBYGA1UEAwwPcGxheXdyaWdodC10ZXN0MB4XDTIzMDgxMDIyNTc1MFoX +DTMzMDgwNzIyNTc1MFowGjEYMBYGA1UEAwwPcGxheXdyaWdodC10ZXN0MIICIjAN +BgkqhkiG9w0BAQEFAAOCAg8AMIICCgKCAgEArbS99qjKcnHr5G0Zc2xhDaOZnjQv +Fbiqxf/nbXt/7WaqryzpVKu7AT1ainBvuPEo7If9DhVnfF//2pGl0gbU31OU4/mr +ymQmczGEyZvOBDsZhtCif54o5OoO0BjhODNT8OWec9RT87n6RkH58MHlOi8xsPxQ +9n5U1CN/h2DyQF3aRKunEFCgtwPKWSjG+J/TAI9i0aSENXPiR8wjTrjg79s8Ehuj +NN8Wk6rKLU3sepG3GIMID5vLsVa2t9xqn562sP95Ee+Xp2YX3z7oYK99QCJdzacw +alhMHob1GCEKjDyxsD2IFRi7Dysiutfyzy3pMo6NALxFrwKVhWX0L4zVFIsI6JlV +dK8dHmDk0MRSqgB9sWXvEfSTXADEe8rncFSFpFz4Z8RNLmn5YSzQJzokNn41DUCP +dZTlTkcGTqvn5NqoY4sOV8rkFbgmTcqyijV/sebPjxCbJNcNmaSWa9FJ5IjRTpzM +38wLmxn+eKGK68n2JB3P7JP6LtsBShQEpXAF3rFfyNsP1bjquvGZVSjV8w/UwPE4 +kV5eq3j3D4913Zfxvzjp6PEmhStG0EQtIXvx/TRoYpaNWypIgZdbkZQp1HUIQL15 +D2Web4nazP3so1FC3ZgbrJZ2ozoadjLMp49NcSFdh+WRyVKuo0DIqR0zaiAzzf2D +G1q7TLKimM3XBMUCAwEAAaNIMEYwCQYDVR0TBAIwADALBgNVHQ8EBAMCBeAwLAYD +VR0RBCUwI4IJbG9jYWxob3N0hwR/AAABhxAAAAAAAAAAAAAAAAAAAAABMA0GCSqG +SIb3DQEBCwUAA4ICAQAvC5M1JFc21WVSLPvE2iVbt4HmirO3EENdDqs+rTYG5VJG +iE5ZuI6h/LjS5ptTfKovXQKaMr3pwp1pLMd/9q+6ZR1Hs9Z2wF6OZan4sb0uT32Y +1KGlj86QMiiSLdrJ/1Z9JHskHYNCep1ZTsUhGk0qqiNv+G3K2y7ZpvrT/xlnYMth +KLTuSVUwM8BBEPrCRLoXuaEy0LnvMvMVepIfP8tnMIL6zqmj3hXMPe4r4OFV/C5o +XX25bC7GyuPWIRYn2OWP92J1CODZD1rGRoDtmvqrQpHdeX9RYcKH0ZLZoIf5L3Hf +pPUtVkw3QGtjvKeG3b9usxaV9Od2Z08vKKk1PRkXFe8gqaeyicK7YVIOMTSuspAf +JeJEHns6Hg61Exbo7GwdX76xlmQ/Z43E9BPHKgLyZ9WuJ0cysqN4aCyvS9yws9to +ki7iMZqJUsmE2o09n9VaEsX6uQANZtLjI9wf+IgJuueDTNrkzQkhU7pbaPMsSG40 +AgGY/y4BR0H8sbhNnhqtZH7RcXV9VCJoPBAe+YiuXRiXyZHWxwBRyBE3e7g4MKHg +hrWtaWUAs7gbavHwjqgU63iVItDSk7t4fCiEyObjK09AaNf2DjjaSGf8YGza4bNy +BjYinYJ6/eX//gp+abqfocFbBP7D9zRDgMIbVmX/Ey6TghKiLkZOdbzcpO4Wgg== +-----END CERTIFICATE----- diff --git a/tests/testserver/index.ts b/tests/testserver/index.ts new file mode 100644 index 0000000..b40f7f4 --- /dev/null +++ b/tests/testserver/index.ts @@ -0,0 +1,172 @@ +/** + * Copyright 2017 Google Inc. All rights reserved. + * Modifications 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 url from 'node:url'; +import http from 'http'; +import https from 'https'; +import path from 'path'; +import debug from 'debug'; + +const fulfillSymbol = Symbol('fulfil callback'); +const rejectSymbol = Symbol('reject callback'); + +// NOTE: Can be removed when we drop Node.js 18 support and changed to import.meta.filename. +const __filename = url.fileURLToPath(import.meta.url); + +export class TestServer { + private _server: http.Server; + readonly debugServer: any; + private _routes = new Map any>(); + private _csp = new Map(); + private _extraHeaders = new Map(); + private _requestSubscribers = new Map>(); + readonly PORT: number; + readonly PREFIX: string; + readonly CROSS_PROCESS_PREFIX: string; + readonly HELLO_WORLD: string; + + static async create(port: number): Promise { + const server = new TestServer(port); + await new Promise(x => server._server.once('listening', x)); + return server; + } + + static async createHTTPS(port: number): Promise { + const server = new TestServer(port, { + key: await fs.promises.readFile(path.join(path.dirname(__filename), 'key.pem')), + cert: await fs.promises.readFile(path.join(path.dirname(__filename), 'cert.pem')), + passphrase: 'aaaa', + }); + await new Promise(x => server._server.once('listening', x)); + return server; + } + + constructor(port: number, sslOptions?: object) { + if (sslOptions) + this._server = https.createServer(sslOptions, this._onRequest.bind(this)); + else + this._server = http.createServer(this._onRequest.bind(this)); + this._server.listen(port); + this.debugServer = debug('pw:testserver'); + + const cross_origin = '127.0.0.1'; + const same_origin = 'localhost'; + const protocol = sslOptions ? 'https' : 'http'; + this.PORT = port; + this.PREFIX = `${protocol}://${same_origin}:${port}/`; + this.CROSS_PROCESS_PREFIX = `${protocol}://${cross_origin}:${port}/`; + this.HELLO_WORLD = `${this.PREFIX}hello-world`; + } + + setCSP(path: string, csp: string) { + this._csp.set(path, csp); + } + + setExtraHeaders(path: string, object: Record) { + this._extraHeaders.set(path, object); + } + + async stop() { + this.reset(); + await new Promise(x => this._server.close(x)); + } + + route(path: string, handler: (request: http.IncomingMessage, response: http.ServerResponse) => any) { + this._routes.set(path, handler); + } + + setContent(path: string, content: string, mimeType: string) { + this.route(path, (req, res) => { + res.writeHead(200, { 'Content-Type': mimeType }); + res.end(mimeType === 'text/html' ? `${content}` : content); + }); + } + + redirect(from: string, to: string) { + this.route(from, (req, res) => { + const headers = this._extraHeaders.get(req.url!) || {}; + res.writeHead(302, { ...headers, location: to }); + res.end(); + }); + } + + waitForRequest(path: string): Promise { + let promise = this._requestSubscribers.get(path); + if (promise) + return promise; + let fulfill, reject; + promise = new Promise((f, r) => { + fulfill = f; + reject = r; + }); + promise[fulfillSymbol] = fulfill; + promise[rejectSymbol] = reject; + this._requestSubscribers.set(path, promise); + return promise; + } + + reset() { + this._routes.clear(); + this._csp.clear(); + this._extraHeaders.clear(); + this._server.closeAllConnections(); + const error = new Error('Static Server has been reset'); + for (const subscriber of this._requestSubscribers.values()) + subscriber[rejectSymbol].call(null, error); + this._requestSubscribers.clear(); + + this.setContent('/favicon.ico', '', 'image/x-icon'); + + this.setContent('/', ``, 'text/html'); + + this.setContent('/hello-world', ` + Title + Hello, world! + `, 'text/html'); + } + + _onRequest(request: http.IncomingMessage, response: http.ServerResponse) { + request.on('error', error => { + if ((error as any).code === 'ECONNRESET') + response.end(); + else + throw error; + }); + (request as any).postBody = new Promise(resolve => { + const chunks: Buffer[] = []; + request.on('data', chunk => { + chunks.push(chunk); + }); + request.on('end', () => resolve(Buffer.concat(chunks))); + }); + const path = request.url || '/'; + this.debugServer(`request ${request.method} ${path}`); + // Notify request subscriber. + if (this._requestSubscribers.has(path)) { + this._requestSubscribers.get(path)![fulfillSymbol].call(null, request); + this._requestSubscribers.delete(path); + } + const handler = this._routes.get(path); + if (handler) { + handler.call(null, request, response); + } else { + response.writeHead(404); + response.end(); + } + } +} diff --git a/tests/testserver/key.pem b/tests/testserver/key.pem new file mode 100644 index 0000000..28edf51 --- /dev/null +++ b/tests/testserver/key.pem @@ -0,0 +1,52 @@ +-----BEGIN PRIVATE KEY----- +MIIJQgIBADANBgkqhkiG9w0BAQEFAASCCSwwggkoAgEAAoICAQCttL32qMpycevk +bRlzbGENo5meNC8VuKrF/+dte3/tZqqvLOlUq7sBPVqKcG+48Sjsh/0OFWd8X//a +kaXSBtTfU5Tj+avKZCZzMYTJm84EOxmG0KJ/nijk6g7QGOE4M1Pw5Z5z1FPzufpG +QfnwweU6LzGw/FD2flTUI3+HYPJAXdpEq6cQUKC3A8pZKMb4n9MAj2LRpIQ1c+JH +zCNOuODv2zwSG6M03xaTqsotTex6kbcYgwgPm8uxVra33Gqfnraw/3kR75enZhff +Puhgr31AIl3NpzBqWEwehvUYIQqMPLGwPYgVGLsPKyK61/LPLekyjo0AvEWvApWF +ZfQvjNUUiwjomVV0rx0eYOTQxFKqAH2xZe8R9JNcAMR7yudwVIWkXPhnxE0uaflh +LNAnOiQ2fjUNQI91lOVORwZOq+fk2qhjiw5XyuQVuCZNyrKKNX+x5s+PEJsk1w2Z +pJZr0UnkiNFOnMzfzAubGf54oYrryfYkHc/sk/ou2wFKFASlcAXesV/I2w/VuOq6 +8ZlVKNXzD9TA8TiRXl6rePcPj3Xdl/G/OOno8SaFK0bQRC0he/H9NGhilo1bKkiB +l1uRlCnUdQhAvXkPZZ5vidrM/eyjUULdmBuslnajOhp2Msynj01xIV2H5ZHJUq6j +QMipHTNqIDPN/YMbWrtMsqKYzdcExQIDAQABAoICAGqXttpdyZ1g+vg5WpzRrNzJ +v8KtExepMmI+Hq24U1BC6AqG7MfgeejQ1XaOeIBsvEgpSsgRqmdQIZjmN3Mibg59 +I6ih1SFlQ5L8mBd/XHSML6Xi8VSOoVmXp29bVRk/pgr1XL6HVN0DCumCIvXyhc+m +lj+dFbGs5DEpd2CDxSRqcz4gd2wzjevAj7MWqsJ2kOyPEHzFD7wdWIXmZuQv3xhQ +2BPkkcon+5qx+07BupOcR1brUU8Cs4QnSgiZYXSB2GnU215+P/mhVJTR7ZcnGRz5 ++cXxCmy3sj4pYs1juS1FMWSM3azUeDVeqvks+vrXmXpEr5H79mbmlwo8/hMPwNDO +07HRZwa8T01aT9EYVm0lIOYjMF/2f6j6cu2apJtjXICOksR2HefRBVXQirOxRHma +9XAYfNkZ/2164ZbgFmJv9khFnegPEuth9tLVdFIeGSmsG0aX9tH63zGT2NROyyLc +QXPqsDl2CxCYPRs2oiGkM9dnfP1wAOp96sq42GIuN7ykfqfRnwAIvvnLKvyCq1vR +pIno3CIX6vnzt+1/Hrmv13b0L6pJPitpXwKWHv9zJKBTpN8HEzP3Qmth2Ef60/7/ +CBo1PVTd1A6zcU7816flg7SCY+Vk+OxVHV3dGBIIqN9SfrQ8BPcOl6FNV5Anbrnv +CpSw+LzH9n5xympDnk0BAoIBAQDjenvDfCnrNVeqx8+sYaYey4/WPVLXOQhREvRY +oOtX9eqlNSi20+Wl+iuXmyj8wdHrDET7rfjCbpDQ7u105yzLw4gy4qIRDKZ1nE45 +YX+tm8mZgBqRnTp0DoGOArqmp3IKXJtUYmpbTz9tOfY7Usb1o1epb4winEB+Pl+8 +mgXOEo8xvWBzKeRA7tE73V64Mwbvbo9Ff2EguhXweQP29yBkEjT4iViayuHUmyPt +hOVSMj2oFQuQGPdhAk7nUXojSGK/Zas/AGpH9CHH9De0h4m08vd3oM4vj0HwzgjU +Co9aRa9SAH7EiaocOTcjDRPxWdZPHhxmrVRIYlF0MNmOAkXJAoIBAQDDfEqu4sNi +pq74VXVatQqhzCILZo+o48bdgEjF7mF99mqPj8rwIDrEoEriDK861kenLc3vWKRY +5wh1iX3S896re9kUMoxx6p4heYTcsOJ9BbkcpT8bJPZx9gBJb4jJENeVf1exf6sG +RhFnulpzReRRaUjX2yAkyUPfc8YcUt+Nalrg+2W0fzeLCUpABCAcj2B1Vv7qRZHj +oEtlCV5Nz+iMhrwIa16g9c8wGt5DZb4PI+VIJ6EYkdsjhgqIF0T/wDq9/habGBPo +mHN+/DX3hCJWN2QgoVGJskHGt0zDMgiEgXfLZ2Grl02vQtq+mW2O2vGVeUd9Y5Ew +RUiY4bSRTrUdAoIBAHxL1wiP9c/By+9TUtScXssA681ioLtdPIAgXUd4VmAvzVEM +ZPzRd/BjbCJg89p4hZ1rjN4Ax6ZmB9dCVpnEH6QPaYJ0d53dTa+CAvQzpDJWp6eq +adobEW+M5ZmVQCwD3rpus6k+RWMzQDMMstDjgDeEU0gP3YCj5FGW/3TsrDNXzMqe +8e67ey9Hzyho43K+3xFBViPhYE8jnw1Q8quliRtlH3CWi8W5CgDD7LPCJBPvw+Tt +6u2H1tQ5EKgwyw4wZVSz1wiLz4cVjMfXWADa9pHbGQFS6pbuLlfIHObQBliLLysd +ficiGcNmOAx8/uKn9gQxLc+k8iLDJkLY1mdUMpECggEAJLl87k37ltTpmg2z9k58 +qNjIrIugAYKJIaOwCD84YYmhi0bgQSxM3hOe/ciUQuFupKGeRpDIj0sX87zYvoDC +HEUwCvNUHzKMco15wFwasJIarJ7+tALFqbMlaqZhdCSN27AIsXfikVMogewoge9n +bUPyQ1sPNtn4vknptfh7tv18BTg1aytbK+ua31vnDHaDEIg/a5OWTMUYZOrVpJii +f4PwX0SMioCjY84oY1EB26ZKtLt9MDh2ir3rzJVSiRl776WEaa6kTtYVHI4VNWLF +cJ0HWnnz74JliQd2jFUh9IK+FqBdYPcTyREuNxBr3KKVMBeQrqW96OubL913JrU6 +oQKCAQEA0yzORUouT0yleWs7RmzBlT9OLD/3cBYJMf/r1F8z8OQjB8fU1jKbO1Cs +q4l+o9FmI+eHkgc3xbEG0hahOFWm/hTTli9vzksxurgdawZELThRkK33uTU9pKla +Okqx3Ru/iMOW2+DQUx9UB+jK+hSAgq4gGqLeJVyaBerIdLQLlvqxrwSxjvvj+wJC +Y66mgRzdCi6VDF1vV0knCrQHK6tRwcPozu/k4zjJzvdbMJnKEy2S7Vh6vO8lEPJm +MQtaHPpmz+F4z14b9unNIiSbHO60Q4O+BwIBCzxApQQbFg63vBLYYwEMRd7hh92s +ZkZVSOEp+sYBf/tmptlKr49nO+dTjQ== +-----END PRIVATE KEY----- diff --git a/tests/testserver/san.cnf b/tests/testserver/san.cnf new file mode 100644 index 0000000..2f4864b --- /dev/null +++ b/tests/testserver/san.cnf @@ -0,0 +1,19 @@ +# openssl req -new -x509 -days 3650 -key key.pem -out cert.pem -config san.cnf -extensions v3_req + +[req] +distinguished_name = req_distinguished_name +req_extensions = v3_req +prompt = no + +[req_distinguished_name] +CN = playwright-test + +[v3_req] +basicConstraints = CA:FALSE +keyUsage = nonRepudiation, digitalSignature, keyEncipherment +subjectAltName = @alt_names + +[alt_names] +DNS.1 = localhost +IP.1 = 127.0.0.1 +IP.2 = ::1 diff --git a/tests/trace.spec.ts b/tests/trace.spec.ts new file mode 100644 index 0000000..13e9d4f --- /dev/null +++ b/tests/trace.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 fs from 'fs'; +import path from 'path'; + +import { test, expect } from './fixtures.js'; + +test('check that trace is saved', async ({ startClient, server }, testInfo) => { + const outputDir = testInfo.outputPath('output'); + + const { client } = await startClient({ + args: ['--save-trace', `--output-dir=${outputDir}`], + }); + + expect(await client.callTool({ + name: 'browser_navigate', + arguments: { url: server.HELLO_WORLD }, + })).toContainTextContent(`Navigate to http://localhost`); + + expect(fs.existsSync(path.join(outputDir, 'traces', 'trace.trace'))).toBeTruthy(); +}); diff --git a/tests/wait.spec.ts b/tests/wait.spec.ts new file mode 100644 index 0000000..d29a09a --- /dev/null +++ b/tests/wait.spec.ts @@ -0,0 +1,85 @@ +/** + * Copyright (c) Microsoft Corporation. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { test, expect } from './fixtures.js'; + +test('browser_wait_for(text)', async ({ client, server }) => { + server.setContent('/', ` + + + +
Text to disappear
+ + `, 'text/html'); + + await client.callTool({ + name: 'browser_navigate', + arguments: { url: server.PREFIX }, + }); + + await client.callTool({ + name: 'browser_click', + arguments: { + element: 'Click me', + ref: 'e2', + }, + }); + + expect(await client.callTool({ + name: 'browser_wait_for', + arguments: { text: 'Text to appear' }, + })).toContainTextContent(`- generic [ref=e3]: Text to appear`); +}); + +test('browser_wait_for(textGone)', async ({ client, server }) => { + server.setContent('/', ` + + + +
Text to disappear
+ + `, 'text/html'); + + await client.callTool({ + name: 'browser_navigate', + arguments: { url: server.PREFIX }, + }); + + await client.callTool({ + name: 'browser_click', + arguments: { + element: 'Click me', + ref: 'e2', + }, + }); + + expect(await client.callTool({ + name: 'browser_wait_for', + arguments: { textGone: 'Text to disappear' }, + })).toContainTextContent(`- generic [ref=e3]: Text to appear`); +}); diff --git a/tests/webdriver.spec.ts b/tests/webdriver.spec.ts new file mode 100644 index 0000000..faf5fdc --- /dev/null +++ b/tests/webdriver.spec.ts @@ -0,0 +1,38 @@ +/** + * 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('do not falsely advertise user agent as a test driver', async ({ client, server, mcpBrowser }) => { + test.skip(mcpBrowser === 'firefox'); + test.skip(mcpBrowser === 'webkit'); + server.route('/', (req, res) => { + res.writeHead(200, { 'Content-Type': 'text/html' }); + res.end(` + + + `); + }); + + expect(await client.callTool({ + name: 'browser_navigate', + arguments: { + url: server.PREFIX, + }, + })).toContainTextContent('webdriver: false'); +}); diff --git a/tsconfig.all.json b/tsconfig.all.json new file mode 100644 index 0000000..2e60f02 --- /dev/null +++ b/tsconfig.all.json @@ -0,0 +1,4 @@ +{ + "extends": "./tsconfig.json", + "include": ["**/*.ts", "**/*.js"], +} diff --git a/tsconfig.json b/tsconfig.json index 7bc09d3..114ce8b 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,12 +1,13 @@ { "compilerOptions": { "target": "ESNext", - "skipLibCheck": true, "esModuleInterop": true, - "moduleResolution": "node", + "moduleResolution": "nodenext", "strict": true, - "module": "CommonJS", - "outDir": "./lib" + "module": "NodeNext", + "rootDir": "src", + "outDir": "./lib", + "resolveJsonModule": true }, "include": [ "src", diff --git a/utils/generate-links.js b/utils/generate-links.js new file mode 100644 index 0000000..a54fe9c --- /dev/null +++ b/utils/generate-links.js @@ -0,0 +1,6 @@ +const config = JSON.stringify({ name: 'playwright', command: 'npx', args: ["@playwright/mcp@latest"] }); +const urlForWebsites = `vscode:mcp/install?${encodeURIComponent(config)}`; +// Github markdown does not allow linking to `vscode:` directly, so you can use our redirect: +const urlForGithub = `https://insiders.vscode.dev/redirect?url=${encodeURIComponent(urlForWebsites)}`; + +console.log(urlForGithub); \ No newline at end of file diff --git a/utils/update-readme.js b/utils/update-readme.js new file mode 100644 index 0000000..90d8c4b --- /dev/null +++ b/utils/update-readme.js @@ -0,0 +1,194 @@ +#!/usr/bin/env node +/** + * 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. + */ +// @ts-check + +import fs from 'node:fs' +import path from 'node:path' +import url from 'node:url' +import zodToJsonSchema from 'zod-to-json-schema' + +import commonTools from '../lib/tools/common.js'; +import consoleTools from '../lib/tools/console.js'; +import dialogsTools from '../lib/tools/dialogs.js'; +import filesTools from '../lib/tools/files.js'; +import installTools from '../lib/tools/install.js'; +import keyboardTools from '../lib/tools/keyboard.js'; +import navigateTools from '../lib/tools/navigate.js'; +import networkTools from '../lib/tools/network.js'; +import pdfTools from '../lib/tools/pdf.js'; +import snapshotTools from '../lib/tools/snapshot.js'; +import tabsTools from '../lib/tools/tabs.js'; +import screenshotTools from '../lib/tools/screenshot.js'; +import testTools from '../lib/tools/testing.js'; +import visionTools from '../lib/tools/vision.js'; +import waitTools from '../lib/tools/wait.js'; +import { execSync } from 'node:child_process'; + +const categories = { + 'Interactions': [ + ...snapshotTools, + ...keyboardTools(true), + ...waitTools(true), + ...filesTools(true), + ...dialogsTools(true), + ], + 'Navigation': [ + ...navigateTools(true), + ], + 'Resources': [ + ...screenshotTools, + ...pdfTools, + ...networkTools, + ...consoleTools, + ], + 'Utilities': [ + ...installTools, + ...commonTools(true), + ], + 'Tabs': [ + ...tabsTools(true), + ], + 'Testing': [ + ...testTools, + ], + 'Vision mode': [ + ...visionTools, + ...keyboardTools(), + ...waitTools(false), + ...filesTools(false), + ...dialogsTools(false), + ], +}; + +// NOTE: Can be removed when we drop Node.js 18 support and changed to import.meta.filename. +const __filename = url.fileURLToPath(import.meta.url); + +/** + * @param {import('../src/tools/tool.js').ToolSchema} tool + * @returns {string[]} + */ +function formatToolForReadme(tool) { + const lines = /** @type {string[]} */ ([]); + lines.push(``); + lines.push(``); + lines.push(`- **${tool.name}**`); + lines.push(` - Title: ${tool.title}`); + lines.push(` - Description: ${tool.description}`); + + const inputSchema = /** @type {any} */ (zodToJsonSchema(tool.inputSchema || {})); + const requiredParams = inputSchema.required || []; + if (inputSchema.properties && Object.keys(inputSchema.properties).length) { + lines.push(` - Parameters:`); + Object.entries(inputSchema.properties).forEach(([name, param]) => { + const optional = !requiredParams.includes(name); + const meta = /** @type {string[]} */ ([]); + if (param.type) + meta.push(param.type); + if (optional) + meta.push('optional'); + lines.push(` - \`${name}\` ${meta.length ? `(${meta.join(', ')})` : ''}: ${param.description}`); + }); + } else { + lines.push(` - Parameters: None`); + } + lines.push(` - Read-only: **${tool.type === 'readOnly'}**`); + lines.push(''); + return lines; +} + +/** + * @param {string} content + * @param {string} startMarker + * @param {string} endMarker + * @param {string[]} generatedLines + * @returns {Promise} + */ +async function updateSection(content, startMarker, endMarker, generatedLines) { + const startMarkerIndex = content.indexOf(startMarker); + const endMarkerIndex = content.indexOf(endMarker); + if (startMarkerIndex === -1 || endMarkerIndex === -1) + throw new Error('Markers for generated section not found in README'); + + return [ + content.slice(0, startMarkerIndex + startMarker.length), + '', + generatedLines.join('\n'), + '', + content.slice(endMarkerIndex), + ].join('\n'); +} + +/** + * @param {string} content + * @returns {Promise} + */ +async function updateTools(content) { + console.log('Loading tool information from compiled modules...'); + + const totalTools = Object.values(categories).flat().length; + console.log(`Found ${totalTools} tools`); + + const generatedLines = /** @type {string[]} */ ([]); + for (const [category, categoryTools] of Object.entries(categories)) { + generatedLines.push(`
\n${category}`); + generatedLines.push(''); + for (const tool of categoryTools) + generatedLines.push(...formatToolForReadme(tool.schema)); + generatedLines.push(`
`); + generatedLines.push(''); + } + + const startMarker = ``; + const endMarker = ``; + return updateSection(content, startMarker, endMarker, generatedLines); +} + +/** + * @param {string} content + * @returns {Promise} + */ +async function updateOptions(content) { + console.log('Listing options...'); + const output = execSync('node cli.js --help'); + const lines = output.toString().split('\n'); + const firstLine = lines.findIndex(line => line.includes('--version')); + lines.splice(0, firstLine + 1); + const lastLine = lines.findIndex(line => line.includes('--help')); + lines.splice(lastLine); + const startMarker = ``; + const endMarker = ``; + return updateSection(content, startMarker, endMarker, [ + '```', + '> npx @playwright/mcp@latest --help', + ...lines, + '```', + ]); +} + +async function updateReadme() { + const readmePath = path.join(path.dirname(__filename), '..', 'README.md'); + const readmeContent = await fs.promises.readFile(readmePath, 'utf-8'); + const withTools = await updateTools(readmeContent); + const withOptions = await updateOptions(withTools); + await fs.promises.writeFile(readmePath, withOptions, 'utf-8'); + console.log('README updated successfully'); +} + +updateReadme().catch(err => { + console.error('Error updating README:', err); + process.exit(1); +});