├── .github └── workflows │ ├── ci.yml │ └── publish.yml ├── .gitignore ├── .npmignore ├── Dockerfile ├── LICENSE ├── README.md ├── SECURITY.md ├── cli.js ├── config.d.ts ├── eslint.config.mjs ├── examples └── generate-test.md ├── index.d.ts ├── index.js ├── package-lock.json ├── package.json ├── playwright.config.ts ├── src ├── browserContextFactory.ts ├── browserServer.ts ├── config.ts ├── connection.ts ├── context.ts ├── fileUtils.ts ├── httpServer.ts ├── index.ts ├── javascript.ts ├── manualPromise.ts ├── package.ts ├── pageSnapshot.ts ├── program.ts ├── resources │ └── resource.ts ├── server.ts ├── tab.ts ├── tools.ts ├── tools │ ├── common.ts │ ├── console.ts │ ├── dialogs.ts │ ├── files.ts │ ├── install.ts │ ├── keyboard.ts │ ├── navigate.ts │ ├── network.ts │ ├── pdf.ts │ ├── screenshot.ts │ ├── snapshot.ts │ ├── tabs.ts │ ├── testing.ts │ ├── tool.ts │ ├── utils.ts │ ├── vision.ts │ └── wait.ts └── transport.ts ├── tests ├── browser-server.spec.ts ├── capabilities.spec.ts ├── cdp.spec.ts ├── config.spec.ts ├── console.spec.ts ├── core.spec.ts ├── device.spec.ts ├── dialogs.spec.ts ├── files.spec.ts ├── fixtures.ts ├── headed.spec.ts ├── iframes.spec.ts ├── install.spec.ts ├── launch.spec.ts ├── library.spec.ts ├── network.spec.ts ├── pdf.spec.ts ├── request-blocking.spec.ts ├── screenshot.spec.ts ├── sse.spec.ts ├── tabs.spec.ts ├── testserver │ ├── cert.pem │ ├── index.ts │ ├── key.pem │ └── san.cnf ├── trace.spec.ts ├── wait.spec.ts └── webdriver.spec.ts ├── tsconfig.all.json ├── tsconfig.json └── utils ├── copyright.js ├── generate-links.js └── update-readme.js /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | push: 5 | branches: [ main ] 6 | pull_request: 7 | branches: [ main ] 8 | 9 | jobs: 10 | lint: 11 | runs-on: ubuntu-latest 12 | steps: 13 | - uses: actions/checkout@v4 14 | - name: Use Node.js 18 15 | uses: actions/setup-node@v4 16 | with: 17 | node-version: '18' 18 | cache: 'npm' 19 | - name: Install dependencies 20 | run: npm ci 21 | - run: npm run build 22 | - name: Run ESLint 23 | run: npm run lint 24 | - name: Ensure no changes 25 | run: git diff --exit-code 26 | 27 | test: 28 | strategy: 29 | fail-fast: false 30 | matrix: 31 | os: [ubuntu-latest, macos-latest, windows-latest] 32 | runs-on: ${{ matrix.os }} 33 | steps: 34 | - uses: actions/checkout@v4 35 | - name: Use Node.js 18 36 | uses: actions/setup-node@v4 37 | with: 38 | # https://github.com/microsoft/playwright-mcp/issues/344 39 | node-version: '18.19' 40 | cache: 'npm' 41 | - name: Install dependencies 42 | run: npm ci 43 | - name: Playwright install 44 | run: npx playwright install --with-deps 45 | - name: Install MS Edge 46 | # MS Edge is not preinstalled on macOS runners. 47 | if: ${{ matrix.os == 'macos-latest' }} 48 | run: npx playwright install msedge 49 | - name: Build 50 | run: npm run build 51 | - name: Run tests 52 | run: npm test 53 | 54 | test_docker: 55 | runs-on: ubuntu-latest 56 | steps: 57 | - uses: actions/checkout@v4 58 | - name: Use Node.js 18 59 | uses: actions/setup-node@v4 60 | with: 61 | node-version: '18' 62 | cache: 'npm' 63 | - name: Install dependencies 64 | run: npm ci 65 | - name: Playwright install 66 | run: npx playwright install --with-deps chromium 67 | - name: Build 68 | run: npm run build 69 | - name: Set up Docker Buildx 70 | uses: docker/setup-buildx-action@v3 71 | - name: Build and push 72 | uses: docker/build-push-action@v6 73 | with: 74 | tags: playwright-mcp-dev:latest 75 | cache-from: type=gha 76 | cache-to: type=gha,mode=max 77 | load: true 78 | - name: Run tests 79 | shell: bash 80 | run: | 81 | # Used for the Docker tests to share the test-results folder with the container. 82 | umask 0000 83 | npm run test -- --project=chromium-docker 84 | env: 85 | MCP_IN_DOCKER: 1 86 | -------------------------------------------------------------------------------- /.github/workflows/publish.yml: -------------------------------------------------------------------------------- 1 | name: Publish 2 | on: 3 | release: 4 | types: [published] 5 | jobs: 6 | publish-npm: 7 | runs-on: ubuntu-latest 8 | permissions: 9 | contents: read 10 | id-token: write # Needed for npm provenance 11 | steps: 12 | - uses: actions/checkout@v4 13 | - uses: actions/setup-node@v4 14 | with: 15 | node-version: 18 16 | registry-url: https://registry.npmjs.org/ 17 | - run: npm ci 18 | - run: npx playwright install --with-deps 19 | - run: npm run build 20 | - run: npm run lint 21 | - run: npm run ctest 22 | - run: npm publish --provenance 23 | env: 24 | NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} 25 | 26 | publish-docker: 27 | runs-on: ubuntu-latest 28 | permissions: 29 | contents: read 30 | id-token: write # Needed for OIDC login to Azure 31 | environment: allow-publishing-docker-to-acr 32 | steps: 33 | - uses: actions/checkout@v4 34 | - name: Set up QEMU # Needed for multi-platform builds (e.g., arm64 on amd64 runner) 35 | uses: docker/setup-qemu-action@v3 36 | - name: Set up Docker Buildx # Needed for multi-platform builds 37 | uses: docker/setup-buildx-action@v3 38 | - name: Azure Login via OIDC 39 | uses: azure/login@v2 40 | with: 41 | client-id: ${{ secrets.AZURE_DOCKER_CLIENT_ID }} 42 | tenant-id: ${{ secrets.AZURE_DOCKER_TENANT_ID }} 43 | subscription-id: ${{ secrets.AZURE_DOCKER_SUBSCRIPTION_ID }} 44 | - name: Login to ACR 45 | run: az acr login --name playwright 46 | - name: Build and push Docker image 47 | uses: docker/build-push-action@v6 48 | with: 49 | context: . 50 | file: ./Dockerfile # Adjust path if your Dockerfile is elsewhere 51 | platforms: linux/amd64,linux/arm64 52 | push: true 53 | tags: | 54 | playwright.azurecr.io/public/playwright/mcp:${{ github.event.release.tag_name }} 55 | playwright.azurecr.io/public/playwright/mcp:latest 56 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | lib/ 2 | node_modules/ 3 | test-results/ 4 | playwright-report/ 5 | .vscode/mcp.json 6 | 7 | .idea 8 | .DS_Store 9 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | **/* 2 | README.md 3 | LICENSE 4 | !lib/**/*.js 5 | !cli.js 6 | !index.* 7 | !config.d.ts 8 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | ARG PLAYWRIGHT_BROWSERS_PATH=/ms-playwright 2 | 3 | # ------------------------------ 4 | # Base 5 | # ------------------------------ 6 | # Base stage: Contains only the minimal dependencies required for runtime 7 | # (node_modules and Playwright system dependencies) 8 | FROM node:22-bookworm-slim AS base 9 | 10 | ARG PLAYWRIGHT_BROWSERS_PATH 11 | ENV PLAYWRIGHT_BROWSERS_PATH=${PLAYWRIGHT_BROWSERS_PATH} 12 | 13 | # Set the working directory 14 | WORKDIR /app 15 | 16 | RUN --mount=type=cache,target=/root/.npm,sharing=locked,id=npm-cache \ 17 | --mount=type=bind,source=package.json,target=package.json \ 18 | --mount=type=bind,source=package-lock.json,target=package-lock.json \ 19 | npm ci --omit=dev && \ 20 | # Install system dependencies for playwright 21 | npx -y playwright-core install-deps chromium 22 | 23 | # ------------------------------ 24 | # Builder 25 | # ------------------------------ 26 | FROM base AS builder 27 | 28 | RUN --mount=type=cache,target=/root/.npm,sharing=locked,id=npm-cache \ 29 | --mount=type=bind,source=package.json,target=package.json \ 30 | --mount=type=bind,source=package-lock.json,target=package-lock.json \ 31 | npm ci 32 | 33 | # Copy the rest of the app 34 | COPY *.json *.js *.ts . 35 | COPY src src/ 36 | 37 | # Build the app 38 | RUN npm run build 39 | 40 | # ------------------------------ 41 | # Browser 42 | # ------------------------------ 43 | # Cache optimization: 44 | # - Browser is downloaded only when node_modules or Playwright system dependencies change 45 | # - Cache is reused when only source code changes 46 | FROM base AS browser 47 | 48 | RUN npx -y playwright-core install --no-shell chromium 49 | 50 | # ------------------------------ 51 | # Runtime 52 | # ------------------------------ 53 | FROM base 54 | 55 | ARG PLAYWRIGHT_BROWSERS_PATH 56 | ARG USERNAME=node 57 | ENV NODE_ENV=production 58 | 59 | # Set the correct ownership for the runtime user on production `node_modules` 60 | RUN chown -R ${USERNAME}:${USERNAME} node_modules 61 | 62 | USER ${USERNAME} 63 | 64 | COPY --from=browser --chown=${USERNAME}:${USERNAME} ${PLAYWRIGHT_BROWSERS_PATH} ${PLAYWRIGHT_BROWSERS_PATH} 65 | COPY --chown=${USERNAME}:${USERNAME} cli.js package.json ./ 66 | COPY --from=builder --chown=${USERNAME}:${USERNAME} /app/lib /app/lib 67 | 68 | # Run in headless and only with chromium (other browsers need more dependencies not included in this image) 69 | ENTRYPOINT ["node", "cli.js", "--headless", "--browser", "chromium", "--no-sandbox"] 70 | -------------------------------------------------------------------------------- /SECURITY.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | ## Security 4 | 5 | Microsoft takes the security of our software products and services seriously, which includes all source code repositories managed through our GitHub organizations, which include [Microsoft](https://github.com/Microsoft), [Azure](https://github.com/Azure), [DotNet](https://github.com/dotnet), [AspNet](https://github.com/aspnet) and [Xamarin](https://github.com/xamarin). 6 | 7 | If you believe you have found a security vulnerability in any Microsoft-owned repository that meets [Microsoft's definition of a security vulnerability](https://aka.ms/security.md/definition), please report it to us as described below. 8 | 9 | ## Reporting Security Issues 10 | 11 | **Please do not report security vulnerabilities through public GitHub issues.** 12 | 13 | Instead, please report them to the Microsoft Security Response Center (MSRC) at [https://msrc.microsoft.com/create-report](https://aka.ms/security.md/msrc/create-report). 14 | 15 | If you prefer to submit without logging in, send email to [secure@microsoft.com](mailto:secure@microsoft.com). If possible, encrypt your message with our PGP key; please download it from the [Microsoft Security Response Center PGP Key page](https://aka.ms/security.md/msrc/pgp). 16 | 17 | You should receive a response within 24 hours. If for some reason you do not, please follow up via email to ensure we received your original message. Additional information can be found at [microsoft.com/msrc](https://www.microsoft.com/msrc). 18 | 19 | Please include the requested information listed below (as much as you can provide) to help us better understand the nature and scope of the possible issue: 20 | 21 | * Type of issue (e.g. buffer overflow, SQL injection, cross-site scripting, etc.) 22 | * Full paths of source file(s) related to the manifestation of the issue 23 | * The location of the affected source code (tag/branch/commit or direct URL) 24 | * Any special configuration required to reproduce the issue 25 | * Step-by-step instructions to reproduce the issue 26 | * Proof-of-concept or exploit code (if possible) 27 | * Impact of the issue, including how an attacker might exploit the issue 28 | 29 | This information will help us triage your report more quickly. 30 | 31 | If you are reporting for a bug bounty, more complete reports can contribute to a higher bounty award. Please visit our [Microsoft Bug Bounty Program](https://aka.ms/security.md/msrc/bounty) page for more details about our active programs. 32 | 33 | ## Preferred Languages 34 | 35 | We prefer all communications to be in English. 36 | 37 | ## Policy 38 | 39 | Microsoft follows the principle of [Coordinated Vulnerability Disclosure](https://aka.ms/security.md/cvd). 40 | 41 | 42 | -------------------------------------------------------------------------------- /cli.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | /** 3 | * Copyright (c) Microsoft Corporation. 4 | * 5 | * Licensed under the Apache License, Version 2.0 (the "License"); 6 | * you may not use this file except in compliance with the License. 7 | * You may obtain a copy of the License at 8 | * 9 | * http://www.apache.org/licenses/LICENSE-2.0 10 | * 11 | * Unless required by applicable law or agreed to in writing, software 12 | * distributed under the License is distributed on an "AS IS" BASIS, 13 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 14 | * See the License for the specific language governing permissions and 15 | * limitations under the License. 16 | */ 17 | 18 | import './lib/program.js'; 19 | -------------------------------------------------------------------------------- /config.d.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright (c) Microsoft Corporation. 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | import type * as playwright from 'playwright'; 18 | 19 | export type ToolCapability = 'core' | 'tabs' | 'pdf' | 'history' | 'wait' | 'files' | 'install' | 'testing'; 20 | 21 | export type Config = { 22 | /** 23 | * The browser to use. 24 | */ 25 | browser?: { 26 | /** 27 | * Use browser agent (experimental). 28 | */ 29 | browserAgent?: string; 30 | 31 | /** 32 | * The type of browser to use. 33 | */ 34 | browserName?: 'chromium' | 'firefox' | 'webkit'; 35 | 36 | /** 37 | * Keep the browser profile in memory, do not save it to disk. 38 | */ 39 | isolated?: boolean; 40 | 41 | /** 42 | * Path to a user data directory for browser profile persistence. 43 | * Temporary directory is created by default. 44 | */ 45 | userDataDir?: string; 46 | 47 | /** 48 | * Launch options passed to 49 | * @see https://playwright.dev/docs/api/class-browsertype#browser-type-launch-persistent-context 50 | * 51 | * This is useful for settings options like `channel`, `headless`, `executablePath`, etc. 52 | */ 53 | launchOptions?: playwright.LaunchOptions; 54 | 55 | /** 56 | * Context options for the browser context. 57 | * 58 | * This is useful for settings options like `viewport`. 59 | */ 60 | contextOptions?: playwright.BrowserContextOptions; 61 | 62 | /** 63 | * Chrome DevTools Protocol endpoint to connect to an existing browser instance in case of Chromium family browsers. 64 | */ 65 | cdpEndpoint?: string; 66 | 67 | /** 68 | * Remote endpoint to connect to an existing Playwright server. 69 | */ 70 | remoteEndpoint?: string; 71 | }, 72 | 73 | server?: { 74 | /** 75 | * The port to listen on for SSE or MCP transport. 76 | */ 77 | port?: number; 78 | 79 | /** 80 | * The host to bind the server to. Default is localhost. Use 0.0.0.0 to bind to all interfaces. 81 | */ 82 | host?: string; 83 | }, 84 | 85 | /** 86 | * List of enabled tool capabilities. Possible values: 87 | * - 'core': Core browser automation features. 88 | * - 'tabs': Tab management features. 89 | * - 'pdf': PDF generation and manipulation. 90 | * - 'history': Browser history access. 91 | * - 'wait': Wait and timing utilities. 92 | * - 'files': File upload/download support. 93 | * - 'install': Browser installation utilities. 94 | */ 95 | capabilities?: ToolCapability[]; 96 | 97 | /** 98 | * Run server that uses screenshots (Aria snapshots are used by default). 99 | */ 100 | vision?: boolean; 101 | 102 | /** 103 | * Whether to save the Playwright trace of the session into the output directory. 104 | */ 105 | saveTrace?: boolean; 106 | 107 | /** 108 | * The directory to save output files. 109 | */ 110 | outputDir?: string; 111 | 112 | network?: { 113 | /** 114 | * List of origins to allow the browser to request. Default is to allow all. Origins matching both `allowedOrigins` and `blockedOrigins` will be blocked. 115 | */ 116 | allowedOrigins?: string[]; 117 | 118 | /** 119 | * List of origins to block the browser to request. Origins matching both `allowedOrigins` and `blockedOrigins` will be blocked. 120 | */ 121 | blockedOrigins?: string[]; 122 | }; 123 | 124 | /** 125 | * 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. 126 | */ 127 | imageResponses?: 'allow' | 'omit' | 'auto'; 128 | }; 129 | -------------------------------------------------------------------------------- /eslint.config.mjs: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright (c) Microsoft Corporation. 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | import typescriptEslint from "@typescript-eslint/eslint-plugin"; 18 | import tsParser from "@typescript-eslint/parser"; 19 | import notice from "eslint-plugin-notice"; 20 | import path from "path"; 21 | import { fileURLToPath } from "url"; 22 | import stylistic from "@stylistic/eslint-plugin"; 23 | import importRules from "eslint-plugin-import"; 24 | 25 | const __filename = fileURLToPath(import.meta.url); 26 | const __dirname = path.dirname(__filename); 27 | 28 | const plugins = { 29 | "@stylistic": stylistic, 30 | "@typescript-eslint": typescriptEslint, 31 | notice, 32 | import: importRules, 33 | }; 34 | 35 | export const baseRules = { 36 | "import/extensions": ["error", "ignorePackages", {ts: "always"}], 37 | "@typescript-eslint/no-floating-promises": "error", 38 | "@typescript-eslint/no-unused-vars": [ 39 | 2, 40 | { args: "none", caughtErrors: "none" }, 41 | ], 42 | 43 | /** 44 | * Enforced rules 45 | */ 46 | // syntax preferences 47 | "object-curly-spacing": ["error", "always"], 48 | quotes: [ 49 | 2, 50 | "single", 51 | { 52 | avoidEscape: true, 53 | allowTemplateLiterals: true, 54 | }, 55 | ], 56 | "jsx-quotes": [2, "prefer-single"], 57 | "no-extra-semi": 2, 58 | "@stylistic/semi": [2], 59 | "comma-style": [2, "last"], 60 | "wrap-iife": [2, "inside"], 61 | "spaced-comment": [ 62 | 2, 63 | "always", 64 | { 65 | markers: ["*"], 66 | }, 67 | ], 68 | eqeqeq: [2], 69 | "accessor-pairs": [ 70 | 2, 71 | { 72 | getWithoutSet: false, 73 | setWithoutGet: false, 74 | }, 75 | ], 76 | "brace-style": [2, "1tbs", { allowSingleLine: true }], 77 | curly: [2, "multi-or-nest", "consistent"], 78 | "new-parens": 2, 79 | "arrow-parens": [2, "as-needed"], 80 | "prefer-const": 2, 81 | "quote-props": [2, "consistent"], 82 | "nonblock-statement-body-position": [2, "below"], 83 | 84 | // anti-patterns 85 | "no-var": 2, 86 | "no-with": 2, 87 | "no-multi-str": 2, 88 | "no-caller": 2, 89 | "no-implied-eval": 2, 90 | "no-labels": 2, 91 | "no-new-object": 2, 92 | "no-octal-escape": 2, 93 | "no-self-compare": 2, 94 | "no-shadow-restricted-names": 2, 95 | "no-cond-assign": 2, 96 | "no-debugger": 2, 97 | "no-dupe-keys": 2, 98 | "no-duplicate-case": 2, 99 | "no-empty-character-class": 2, 100 | "no-unreachable": 2, 101 | "no-unsafe-negation": 2, 102 | radix: 2, 103 | "valid-typeof": 2, 104 | "no-implicit-globals": [2], 105 | "no-unused-expressions": [ 106 | 2, 107 | { allowShortCircuit: true, allowTernary: true, allowTaggedTemplates: true }, 108 | ], 109 | "no-proto": 2, 110 | 111 | // es2015 features 112 | "require-yield": 2, 113 | "template-curly-spacing": [2, "never"], 114 | 115 | // spacing details 116 | "space-infix-ops": 2, 117 | "space-in-parens": [2, "never"], 118 | "array-bracket-spacing": [2, "never"], 119 | "comma-spacing": [2, { before: false, after: true }], 120 | "keyword-spacing": [2, "always"], 121 | "space-before-function-paren": [ 122 | 2, 123 | { 124 | anonymous: "never", 125 | named: "never", 126 | asyncArrow: "always", 127 | }, 128 | ], 129 | "no-whitespace-before-property": 2, 130 | "keyword-spacing": [ 131 | 2, 132 | { 133 | overrides: { 134 | if: { after: true }, 135 | else: { after: true }, 136 | for: { after: true }, 137 | while: { after: true }, 138 | do: { after: true }, 139 | switch: { after: true }, 140 | return: { after: true }, 141 | }, 142 | }, 143 | ], 144 | "arrow-spacing": [ 145 | 2, 146 | { 147 | after: true, 148 | before: true, 149 | }, 150 | ], 151 | "@stylistic/func-call-spacing": 2, 152 | "@stylistic/type-annotation-spacing": 2, 153 | 154 | // file whitespace 155 | "no-multiple-empty-lines": [2, { max: 2, maxEOF: 0 }], 156 | "no-mixed-spaces-and-tabs": 2, 157 | "no-trailing-spaces": 2, 158 | "linebreak-style": [process.platform === "win32" ? 0 : 2, "unix"], 159 | indent: [ 160 | 2, 161 | 2, 162 | { SwitchCase: 1, CallExpression: { arguments: 2 }, MemberExpression: 2 }, 163 | ], 164 | "key-spacing": [ 165 | 2, 166 | { 167 | beforeColon: false, 168 | }, 169 | ], 170 | "eol-last": 2, 171 | 172 | // copyright 173 | "notice/notice": [ 174 | 2, 175 | { 176 | mustMatch: "Copyright", 177 | templateFile: path.join(__dirname, "utils", "copyright.js"), 178 | }, 179 | ], 180 | 181 | // react 182 | "react/react-in-jsx-scope": 0, 183 | "no-console": 2, 184 | }; 185 | 186 | const languageOptions = { 187 | parser: tsParser, 188 | ecmaVersion: 9, 189 | sourceType: "module", 190 | parserOptions: { 191 | project: path.join(fileURLToPath(import.meta.url), "..", "tsconfig.all.json"), 192 | } 193 | }; 194 | 195 | export default [ 196 | { 197 | ignores: ["**/*.js"], 198 | }, 199 | { 200 | files: ["**/*.ts", "**/*.tsx"], 201 | plugins, 202 | languageOptions, 203 | rules: baseRules, 204 | }, 205 | ]; 206 | -------------------------------------------------------------------------------- /examples/generate-test.md: -------------------------------------------------------------------------------- 1 | Use Playwright tools to generate test for scenario: 2 | 3 | ## GitHub PR Checks Navigation Checklist 4 | 5 | 1. Open the [Microsoft Playwright GitHub repository](https://github.com/microsoft/playwright). 6 | 2. Click on the **Pull requests** tab. 7 | 3. Find and open the pull request titled **"chore: make noWaitAfter a default"**. 8 | 4. Switch to the **Checks** tab for that pull request. 9 | 5. Expand the **infra** check suite to view its jobs. 10 | 6. Click on the **docs & lint** job to view its details. 11 | -------------------------------------------------------------------------------- /index.d.ts: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | /** 3 | * Copyright (c) Microsoft Corporation. 4 | * 5 | * Licensed under the Apache License, Version 2.0 (the "License"); 6 | * you may not use this file except in compliance with the License. 7 | * You may obtain a copy of the License at 8 | * 9 | * http://www.apache.org/licenses/LICENSE-2.0 10 | * 11 | * Unless required by applicable law or agreed to in writing, software 12 | * distributed under the License is distributed on an "AS IS" BASIS, 13 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 14 | * See the License for the specific language governing permissions and 15 | * limitations under the License. 16 | */ 17 | 18 | import type { Server } from '@modelcontextprotocol/sdk/server/index.js'; 19 | import type { Config } from './config.js'; 20 | import type { BrowserContext } from 'playwright'; 21 | 22 | export type Connection = { 23 | server: Server; 24 | close(): Promise; 25 | }; 26 | 27 | export declare function createConnection(config?: Config, contextGetter?: () => Promise): Promise; 28 | export {}; 29 | -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | /** 3 | * Copyright (c) Microsoft Corporation. 4 | * 5 | * Licensed under the Apache License, Version 2.0 (the "License"); 6 | * you may not use this file except in compliance with the License. 7 | * You may obtain a copy of the License at 8 | * 9 | * http://www.apache.org/licenses/LICENSE-2.0 10 | * 11 | * Unless required by applicable law or agreed to in writing, software 12 | * distributed under the License is distributed on an "AS IS" BASIS, 13 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 14 | * See the License for the specific language governing permissions and 15 | * limitations under the License. 16 | */ 17 | 18 | import { createConnection } from './lib/index.js'; 19 | export { createConnection }; 20 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@playwright/mcp", 3 | "version": "0.0.28", 4 | "description": "Playwright Tools for MCP", 5 | "type": "module", 6 | "repository": { 7 | "type": "git", 8 | "url": "git+https://github.com/microsoft/playwright-mcp.git" 9 | }, 10 | "homepage": "https://playwright.dev", 11 | "engines": { 12 | "node": ">=18" 13 | }, 14 | "author": { 15 | "name": "Microsoft Corporation" 16 | }, 17 | "license": "Apache-2.0", 18 | "scripts": { 19 | "build": "tsc", 20 | "lint": "npm run update-readme && eslint . && tsc --noEmit", 21 | "update-readme": "node utils/update-readme.js", 22 | "watch": "tsc --watch", 23 | "test": "playwright test", 24 | "ctest": "playwright test --project=chrome", 25 | "ftest": "playwright test --project=firefox", 26 | "wtest": "playwright test --project=webkit", 27 | "run-server": "node lib/browserServer.js", 28 | "clean": "rm -rf lib", 29 | "npm-publish": "npm run clean && npm run build && npm run test && npm publish" 30 | }, 31 | "exports": { 32 | "./package.json": "./package.json", 33 | ".": { 34 | "types": "./index.d.ts", 35 | "default": "./index.js" 36 | } 37 | }, 38 | "dependencies": { 39 | "@modelcontextprotocol/sdk": "^1.11.0", 40 | "commander": "^13.1.0", 41 | "debug": "^4.4.1", 42 | "mime": "^4.0.7", 43 | "playwright": "1.53.0-alpha-2025-05-27", 44 | "zod-to-json-schema": "^3.24.4" 45 | }, 46 | "devDependencies": { 47 | "@eslint/eslintrc": "^3.2.0", 48 | "@eslint/js": "^9.19.0", 49 | "@playwright/test": "1.53.0-alpha-2025-05-27", 50 | "@stylistic/eslint-plugin": "^3.0.1", 51 | "@types/debug": "^4.1.12", 52 | "@types/node": "^22.13.10", 53 | "@typescript-eslint/eslint-plugin": "^8.26.1", 54 | "@typescript-eslint/parser": "^8.26.1", 55 | "@typescript-eslint/utils": "^8.26.1", 56 | "eslint": "^9.19.0", 57 | "eslint-plugin-import": "^2.31.0", 58 | "eslint-plugin-notice": "^1.0.0", 59 | "typescript": "^5.8.2" 60 | }, 61 | "bin": { 62 | "mcp-server-playwright": "cli.js" 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /playwright.config.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright (c) Microsoft Corporation. 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | import { defineConfig } from '@playwright/test'; 18 | 19 | import type { TestOptions } from './tests/fixtures.js'; 20 | 21 | export default defineConfig({ 22 | testDir: './tests', 23 | fullyParallel: true, 24 | forbidOnly: !!process.env.CI, 25 | retries: process.env.CI ? 2 : 0, 26 | workers: process.env.CI ? 1 : undefined, 27 | reporter: 'list', 28 | projects: [ 29 | { name: 'chrome' }, 30 | { name: 'msedge', use: { mcpBrowser: 'msedge' } }, 31 | { name: 'chromium', use: { mcpBrowser: 'chromium' } }, 32 | ...process.env.MCP_IN_DOCKER ? [{ 33 | name: 'chromium-docker', 34 | grep: /browser_navigate|browser_click/, 35 | use: { 36 | mcpBrowser: 'chromium', 37 | mcpMode: 'docker' as const 38 | } 39 | }] : [], 40 | { name: 'firefox', use: { mcpBrowser: 'firefox' } }, 41 | { name: 'webkit', use: { mcpBrowser: 'webkit' } }, 42 | ], 43 | }); 44 | -------------------------------------------------------------------------------- /src/browserServer.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright (c) Microsoft Corporation. 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | /* eslint-disable no-console */ 18 | 19 | import net from 'net'; 20 | 21 | import { program } from 'commander'; 22 | import playwright from 'playwright'; 23 | 24 | import { HttpServer } from './httpServer.js'; 25 | import { packageJSON } from './package.js'; 26 | 27 | import type http from 'http'; 28 | 29 | export type LaunchBrowserRequest = { 30 | browserType: string; 31 | userDataDir: string; 32 | launchOptions: playwright.LaunchOptions; 33 | contextOptions: playwright.BrowserContextOptions; 34 | }; 35 | 36 | export type BrowserInfo = { 37 | browserType: string; 38 | userDataDir: string; 39 | cdpPort: number; 40 | launchOptions: playwright.LaunchOptions; 41 | contextOptions: playwright.BrowserContextOptions; 42 | error?: string; 43 | }; 44 | 45 | type BrowserEntry = { 46 | browser?: playwright.Browser; 47 | info: BrowserInfo; 48 | }; 49 | 50 | class BrowserServer { 51 | private _server = new HttpServer(); 52 | private _entries: BrowserEntry[] = []; 53 | 54 | constructor() { 55 | this._setupExitHandler(); 56 | } 57 | 58 | async start(port: number) { 59 | await this._server.start({ port }); 60 | this._server.routePath('/json/list', (req, res) => { 61 | this._handleJsonList(res); 62 | }); 63 | this._server.routePath('/json/launch', async (req, res) => { 64 | void this._handleLaunchBrowser(req, res).catch(e => console.error(e)); 65 | }); 66 | this._setEntries([]); 67 | } 68 | 69 | private _handleJsonList(res: http.ServerResponse) { 70 | const list = this._entries.map(browser => browser.info); 71 | res.end(JSON.stringify(list)); 72 | } 73 | 74 | private async _handleLaunchBrowser(req: http.IncomingMessage, res: http.ServerResponse) { 75 | const request = await readBody(req); 76 | let info = this._entries.map(entry => entry.info).find(info => info.userDataDir === request.userDataDir); 77 | if (!info || info.error) 78 | info = await this._newBrowser(request); 79 | res.end(JSON.stringify(info)); 80 | } 81 | 82 | private async _newBrowser(request: LaunchBrowserRequest): Promise { 83 | const cdpPort = await findFreePort(); 84 | (request.launchOptions as any).cdpPort = cdpPort; 85 | const info: BrowserInfo = { 86 | browserType: request.browserType, 87 | userDataDir: request.userDataDir, 88 | cdpPort, 89 | launchOptions: request.launchOptions, 90 | contextOptions: request.contextOptions, 91 | }; 92 | 93 | const browserType = playwright[request.browserType as 'chromium' | 'firefox' | 'webkit']; 94 | const { browser, error } = await browserType.launchPersistentContext(request.userDataDir, { 95 | ...request.launchOptions, 96 | ...request.contextOptions, 97 | handleSIGINT: false, 98 | handleSIGTERM: false, 99 | }).then(context => { 100 | return { browser: context.browser()!, error: undefined }; 101 | }).catch(error => { 102 | return { browser: undefined, error: error.message }; 103 | }); 104 | this._setEntries([...this._entries, { 105 | browser, 106 | info: { 107 | browserType: request.browserType, 108 | userDataDir: request.userDataDir, 109 | cdpPort, 110 | launchOptions: request.launchOptions, 111 | contextOptions: request.contextOptions, 112 | error, 113 | }, 114 | }]); 115 | browser?.on('disconnected', () => { 116 | this._setEntries(this._entries.filter(entry => entry.browser !== browser)); 117 | }); 118 | return info; 119 | } 120 | 121 | private _updateReport() { 122 | // Clear the current line and move cursor to top of screen 123 | process.stdout.write('\x1b[2J\x1b[H'); 124 | process.stdout.write(`Playwright Browser Server v${packageJSON.version}\n`); 125 | process.stdout.write(`Listening on ${this._server.urlPrefix('human-readable')}\n\n`); 126 | 127 | if (this._entries.length === 0) { 128 | process.stdout.write('No browsers currently running\n'); 129 | return; 130 | } 131 | 132 | process.stdout.write('Running browsers:\n'); 133 | for (const entry of this._entries) { 134 | const status = entry.browser ? 'running' : 'error'; 135 | const statusColor = entry.browser ? '\x1b[32m' : '\x1b[31m'; // green for running, red for error 136 | process.stdout.write(`${statusColor}${entry.info.browserType}\x1b[0m (${entry.info.userDataDir}) - ${statusColor}${status}\x1b[0m\n`); 137 | if (entry.info.error) 138 | process.stdout.write(` Error: ${entry.info.error}\n`); 139 | } 140 | 141 | } 142 | 143 | private _setEntries(entries: BrowserEntry[]) { 144 | this._entries = entries; 145 | this._updateReport(); 146 | } 147 | 148 | private _setupExitHandler() { 149 | let isExiting = false; 150 | const handleExit = async () => { 151 | if (isExiting) 152 | return; 153 | isExiting = true; 154 | setTimeout(() => process.exit(0), 15000); 155 | for (const entry of this._entries) 156 | await entry.browser?.close().catch(() => {}); 157 | process.exit(0); 158 | }; 159 | 160 | process.stdin.on('close', handleExit); 161 | process.on('SIGINT', handleExit); 162 | process.on('SIGTERM', handleExit); 163 | } 164 | } 165 | 166 | program 167 | .name('browser-agent') 168 | .option('-p, --port ', 'Port to listen on', '9224') 169 | .action(async options => { 170 | await main(options); 171 | }); 172 | 173 | void program.parseAsync(process.argv); 174 | 175 | async function main(options: { port: string }) { 176 | const server = new BrowserServer(); 177 | await server.start(+options.port); 178 | } 179 | 180 | function readBody(req: http.IncomingMessage): Promise { 181 | return new Promise((resolve, reject) => { 182 | const chunks: Buffer[] = []; 183 | req.on('data', (chunk: Buffer) => chunks.push(chunk)); 184 | req.on('end', () => resolve(JSON.parse(Buffer.concat(chunks).toString()))); 185 | }); 186 | } 187 | 188 | async function findFreePort(): Promise { 189 | return new Promise((resolve, reject) => { 190 | const server = net.createServer(); 191 | server.listen(0, () => { 192 | const { port } = server.address() as net.AddressInfo; 193 | server.close(() => resolve(port)); 194 | }); 195 | server.on('error', reject); 196 | }); 197 | } 198 | -------------------------------------------------------------------------------- /src/connection.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright (c) Microsoft Corporation. 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | import { Server as McpServer } from '@modelcontextprotocol/sdk/server/index.js'; 18 | import { CallToolRequestSchema, ListToolsRequestSchema, Tool as McpTool } from '@modelcontextprotocol/sdk/types.js'; 19 | import { zodToJsonSchema } from 'zod-to-json-schema'; 20 | 21 | import { Context } from './context.js'; 22 | import { snapshotTools, visionTools } from './tools.js'; 23 | import { packageJSON } from './package.js'; 24 | 25 | import { FullConfig } from './config.js'; 26 | 27 | import type { BrowserContextFactory } from './browserContextFactory.js'; 28 | 29 | export function createConnection(config: FullConfig, browserContextFactory: BrowserContextFactory): Connection { 30 | const allTools = config.vision ? visionTools : snapshotTools; 31 | const tools = allTools.filter(tool => !config.capabilities || tool.capability === 'core' || config.capabilities.includes(tool.capability)); 32 | 33 | const context = new Context(tools, config, browserContextFactory); 34 | const server = new McpServer({ name: 'Playwright', version: packageJSON.version }, { 35 | capabilities: { 36 | tools: {}, 37 | } 38 | }); 39 | 40 | server.setRequestHandler(ListToolsRequestSchema, async () => { 41 | return { 42 | tools: tools.map(tool => ({ 43 | name: tool.schema.name, 44 | description: tool.schema.description, 45 | inputSchema: zodToJsonSchema(tool.schema.inputSchema), 46 | annotations: { 47 | title: tool.schema.title, 48 | readOnlyHint: tool.schema.type === 'readOnly', 49 | destructiveHint: tool.schema.type === 'destructive', 50 | openWorldHint: true, 51 | }, 52 | })) as McpTool[], 53 | }; 54 | }); 55 | 56 | server.setRequestHandler(CallToolRequestSchema, async request => { 57 | const errorResult = (...messages: string[]) => ({ 58 | content: [{ type: 'text', text: messages.join('\n') }], 59 | isError: true, 60 | }); 61 | const tool = tools.find(tool => tool.schema.name === request.params.name); 62 | if (!tool) 63 | return errorResult(`Tool "${request.params.name}" not found`); 64 | 65 | 66 | const modalStates = context.modalStates().map(state => state.type); 67 | if (tool.clearsModalState && !modalStates.includes(tool.clearsModalState)) 68 | return errorResult(`The tool "${request.params.name}" can only be used when there is related modal state present.`, ...context.modalStatesMarkdown()); 69 | if (!tool.clearsModalState && modalStates.length) 70 | return errorResult(`Tool "${request.params.name}" does not handle the modal state.`, ...context.modalStatesMarkdown()); 71 | 72 | try { 73 | return await context.run(tool, request.params.arguments); 74 | } catch (error) { 75 | return errorResult(String(error)); 76 | } 77 | }); 78 | 79 | return new Connection(server, context); 80 | } 81 | 82 | export class Connection { 83 | readonly server: McpServer; 84 | readonly context: Context; 85 | 86 | constructor(server: McpServer, context: Context) { 87 | this.server = server; 88 | this.context = context; 89 | this.server.oninitialized = () => { 90 | this.context.clientVersion = this.server.getClientVersion(); 91 | }; 92 | } 93 | 94 | async close() { 95 | await this.server.close(); 96 | await this.context.close(); 97 | } 98 | } 99 | -------------------------------------------------------------------------------- /src/fileUtils.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright (c) Microsoft Corporation. 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | import os from 'node:os'; 18 | import path from 'node:path'; 19 | 20 | import type { FullConfig } from './config.js'; 21 | 22 | export function cacheDir() { 23 | let cacheDirectory: string; 24 | if (process.platform === 'linux') 25 | cacheDirectory = process.env.XDG_CACHE_HOME || path.join(os.homedir(), '.cache'); 26 | else if (process.platform === 'darwin') 27 | cacheDirectory = path.join(os.homedir(), 'Library', 'Caches'); 28 | else if (process.platform === 'win32') 29 | cacheDirectory = process.env.LOCALAPPDATA || path.join(os.homedir(), 'AppData', 'Local'); 30 | else 31 | throw new Error('Unsupported platform: ' + process.platform); 32 | return path.join(cacheDirectory, 'ms-playwright'); 33 | } 34 | 35 | export async function userDataDir(browserConfig: FullConfig['browser']) { 36 | return path.join(cacheDir(), 'ms-playwright', `mcp-${browserConfig.launchOptions?.channel ?? browserConfig?.browserName}-profile`); 37 | } 38 | -------------------------------------------------------------------------------- /src/httpServer.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright (c) Microsoft Corporation. 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | import fs from 'fs'; 18 | import path from 'path'; 19 | import http from 'http'; 20 | import net from 'net'; 21 | 22 | import mime from 'mime'; 23 | 24 | import { ManualPromise } from './manualPromise.js'; 25 | 26 | 27 | export type ServerRouteHandler = (request: http.IncomingMessage, response: http.ServerResponse) => void; 28 | 29 | export type Transport = { 30 | sendEvent?: (method: string, params: any) => void; 31 | close?: () => void; 32 | onconnect: () => void; 33 | dispatch: (method: string, params: any) => Promise; 34 | onclose: () => void; 35 | }; 36 | 37 | export class HttpServer { 38 | private _server: http.Server; 39 | private _urlPrefixPrecise: string = ''; 40 | private _urlPrefixHumanReadable: string = ''; 41 | private _port: number = 0; 42 | private _routes: { prefix?: string, exact?: string, handler: ServerRouteHandler }[] = []; 43 | 44 | constructor() { 45 | this._server = http.createServer(this._onRequest.bind(this)); 46 | decorateServer(this._server); 47 | } 48 | 49 | server() { 50 | return this._server; 51 | } 52 | 53 | routePrefix(prefix: string, handler: ServerRouteHandler) { 54 | this._routes.push({ prefix, handler }); 55 | } 56 | 57 | routePath(path: string, handler: ServerRouteHandler) { 58 | this._routes.push({ exact: path, handler }); 59 | } 60 | 61 | port(): number { 62 | return this._port; 63 | } 64 | 65 | private async _tryStart(port: number | undefined, host: string) { 66 | const errorPromise = new ManualPromise(); 67 | const errorListener = (error: Error) => errorPromise.reject(error); 68 | this._server.on('error', errorListener); 69 | 70 | try { 71 | this._server.listen(port, host); 72 | await Promise.race([ 73 | new Promise(cb => this._server!.once('listening', cb)), 74 | errorPromise, 75 | ]); 76 | } finally { 77 | this._server.removeListener('error', errorListener); 78 | } 79 | } 80 | 81 | async start(options: { port?: number, preferredPort?: number, host?: string } = {}): Promise { 82 | const host = options.host || 'localhost'; 83 | if (options.preferredPort) { 84 | try { 85 | await this._tryStart(options.preferredPort, host); 86 | } catch (e: any) { 87 | if (!e || !e.message || !e.message.includes('EADDRINUSE')) 88 | throw e; 89 | await this._tryStart(undefined, host); 90 | } 91 | } else { 92 | await this._tryStart(options.port, host); 93 | } 94 | 95 | const address = this._server.address(); 96 | if (typeof address === 'string') { 97 | this._urlPrefixPrecise = address; 98 | this._urlPrefixHumanReadable = address; 99 | } else { 100 | this._port = address!.port; 101 | const resolvedHost = address!.family === 'IPv4' ? address!.address : `[${address!.address}]`; 102 | this._urlPrefixPrecise = `http://${resolvedHost}:${address!.port}`; 103 | this._urlPrefixHumanReadable = `http://${host}:${address!.port}`; 104 | } 105 | } 106 | 107 | async stop() { 108 | await new Promise(cb => this._server!.close(cb)); 109 | } 110 | 111 | urlPrefix(purpose: 'human-readable' | 'precise'): string { 112 | return purpose === 'human-readable' ? this._urlPrefixHumanReadable : this._urlPrefixPrecise; 113 | } 114 | 115 | serveFile(request: http.IncomingMessage, response: http.ServerResponse, absoluteFilePath: string, headers?: { [name: string]: string }): boolean { 116 | try { 117 | for (const [name, value] of Object.entries(headers || {})) 118 | response.setHeader(name, value); 119 | if (request.headers.range) 120 | this._serveRangeFile(request, response, absoluteFilePath); 121 | else 122 | this._serveFile(response, absoluteFilePath); 123 | return true; 124 | } catch (e) { 125 | return false; 126 | } 127 | } 128 | 129 | _serveFile(response: http.ServerResponse, absoluteFilePath: string) { 130 | const content = fs.readFileSync(absoluteFilePath); 131 | response.statusCode = 200; 132 | const contentType = mime.getType(path.extname(absoluteFilePath)) || 'application/octet-stream'; 133 | response.setHeader('Content-Type', contentType); 134 | response.setHeader('Content-Length', content.byteLength); 135 | response.end(content); 136 | } 137 | 138 | _serveRangeFile(request: http.IncomingMessage, response: http.ServerResponse, absoluteFilePath: string) { 139 | const range = request.headers.range; 140 | if (!range || !range.startsWith('bytes=') || range.includes(', ') || [...range].filter(char => char === '-').length !== 1) { 141 | response.statusCode = 400; 142 | return response.end('Bad request'); 143 | } 144 | 145 | // Parse the range header: https://datatracker.ietf.org/doc/html/rfc7233#section-2.1 146 | const [startStr, endStr] = range.replace(/bytes=/, '').split('-'); 147 | 148 | // Both start and end (when passing to fs.createReadStream) and the range header are inclusive and start counting at 0. 149 | let start: number; 150 | let end: number; 151 | const size = fs.statSync(absoluteFilePath).size; 152 | if (startStr !== '' && endStr === '') { 153 | // No end specified: use the whole file 154 | start = +startStr; 155 | end = size - 1; 156 | } else if (startStr === '' && endStr !== '') { 157 | // No start specified: calculate start manually 158 | start = size - +endStr; 159 | end = size - 1; 160 | } else { 161 | start = +startStr; 162 | end = +endStr; 163 | } 164 | 165 | // Handle unavailable range request 166 | if (Number.isNaN(start) || Number.isNaN(end) || start >= size || end >= size || start > end) { 167 | // Return the 416 Range Not Satisfiable: https://datatracker.ietf.org/doc/html/rfc7233#section-4.4 168 | response.writeHead(416, { 169 | 'Content-Range': `bytes */${size}` 170 | }); 171 | return response.end(); 172 | } 173 | 174 | // Sending Partial Content: https://datatracker.ietf.org/doc/html/rfc7233#section-4.1 175 | response.writeHead(206, { 176 | 'Content-Range': `bytes ${start}-${end}/${size}`, 177 | 'Accept-Ranges': 'bytes', 178 | 'Content-Length': end - start + 1, 179 | 'Content-Type': mime.getType(path.extname(absoluteFilePath))!, 180 | }); 181 | 182 | const readable = fs.createReadStream(absoluteFilePath, { start, end }); 183 | readable.pipe(response); 184 | } 185 | 186 | private _onRequest(request: http.IncomingMessage, response: http.ServerResponse) { 187 | if (request.method === 'OPTIONS') { 188 | response.writeHead(200); 189 | response.end(); 190 | return; 191 | } 192 | 193 | request.on('error', () => response.end()); 194 | try { 195 | if (!request.url) { 196 | response.end(); 197 | return; 198 | } 199 | const url = new URL('http://localhost' + request.url); 200 | for (const route of this._routes) { 201 | if (route.exact && url.pathname === route.exact) { 202 | route.handler(request, response); 203 | return; 204 | } 205 | if (route.prefix && url.pathname.startsWith(route.prefix)) { 206 | route.handler(request, response); 207 | return; 208 | } 209 | } 210 | response.statusCode = 404; 211 | response.end(); 212 | } catch (e) { 213 | response.end(); 214 | } 215 | } 216 | } 217 | 218 | function decorateServer(server: net.Server) { 219 | const sockets = new Set(); 220 | server.on('connection', socket => { 221 | sockets.add(socket); 222 | socket.once('close', () => sockets.delete(socket)); 223 | }); 224 | 225 | const close = server.close; 226 | server.close = (callback?: (err?: Error) => void) => { 227 | for (const socket of sockets) 228 | socket.destroy(); 229 | sockets.clear(); 230 | return close.call(server, callback); 231 | }; 232 | } 233 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright (c) Microsoft Corporation. 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | import { createConnection as createConnectionImpl } from './connection.js'; 18 | import type { Connection } from '../index.js'; 19 | import { resolveConfig } from './config.js'; 20 | import { contextFactory } from './browserContextFactory.js'; 21 | 22 | import type { Config } from '../config.js'; 23 | import type { BrowserContext } from 'playwright'; 24 | import type { BrowserContextFactory } from './browserContextFactory.js'; 25 | 26 | export async function createConnection(userConfig: Config = {}, contextGetter?: () => Promise): Promise { 27 | const config = await resolveConfig(userConfig); 28 | const factory = contextGetter ? new SimpleBrowserContextFactory(contextGetter) : contextFactory(config.browser); 29 | return createConnectionImpl(config, factory); 30 | } 31 | 32 | class SimpleBrowserContextFactory implements BrowserContextFactory { 33 | private readonly _contextGetter: () => Promise; 34 | 35 | constructor(contextGetter: () => Promise) { 36 | this._contextGetter = contextGetter; 37 | } 38 | 39 | async createContext(): Promise<{ browserContext: BrowserContext, close: () => Promise }> { 40 | const browserContext = await this._contextGetter(); 41 | return { 42 | browserContext, 43 | close: () => browserContext.close() 44 | }; 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /src/javascript.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright (c) Microsoft Corporation. 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | // adapted from: 18 | // - https://github.com/microsoft/playwright/blob/76ee48dc9d4034536e3ec5b2c7ce8be3b79418a8/packages/playwright-core/src/utils/isomorphic/stringUtils.ts 19 | // - https://github.com/microsoft/playwright/blob/76ee48dc9d4034536e3ec5b2c7ce8be3b79418a8/packages/playwright-core/src/server/codegen/javascript.ts 20 | 21 | // NOTE: this function should not be used to escape any selectors. 22 | export function escapeWithQuotes(text: string, char: string = '\'') { 23 | const stringified = JSON.stringify(text); 24 | const escapedText = stringified.substring(1, stringified.length - 1).replace(/\\"/g, '"'); 25 | if (char === '\'') 26 | return char + escapedText.replace(/[']/g, '\\\'') + char; 27 | if (char === '"') 28 | return char + escapedText.replace(/["]/g, '\\"') + char; 29 | if (char === '`') 30 | return char + escapedText.replace(/[`]/g, '`') + char; 31 | throw new Error('Invalid escape char'); 32 | } 33 | 34 | export function quote(text: string) { 35 | return escapeWithQuotes(text, '\''); 36 | } 37 | 38 | export function formatObject(value: any, indent = ' '): string { 39 | if (typeof value === 'string') 40 | return quote(value); 41 | if (Array.isArray(value)) 42 | return `[${value.map(o => formatObject(o)).join(', ')}]`; 43 | if (typeof value === 'object') { 44 | const keys = Object.keys(value).filter(key => value[key] !== undefined).sort(); 45 | if (!keys.length) 46 | return '{}'; 47 | const tokens: string[] = []; 48 | for (const key of keys) 49 | tokens.push(`${key}: ${formatObject(value[key])}`); 50 | return `{\n${indent}${tokens.join(`,\n${indent}`)}\n}`; 51 | } 52 | return String(value); 53 | } 54 | -------------------------------------------------------------------------------- /src/manualPromise.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright (c) Microsoft Corporation. 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | export class ManualPromise extends Promise { 18 | private _resolve!: (t: T) => void; 19 | private _reject!: (e: Error) => void; 20 | private _isDone: boolean; 21 | 22 | constructor() { 23 | let resolve: (t: T) => void; 24 | let reject: (e: Error) => void; 25 | super((f, r) => { 26 | resolve = f; 27 | reject = r; 28 | }); 29 | this._isDone = false; 30 | this._resolve = resolve!; 31 | this._reject = reject!; 32 | } 33 | 34 | isDone() { 35 | return this._isDone; 36 | } 37 | 38 | resolve(t: T) { 39 | this._isDone = true; 40 | this._resolve(t); 41 | } 42 | 43 | reject(e: Error) { 44 | this._isDone = true; 45 | this._reject(e); 46 | } 47 | 48 | static override get [Symbol.species]() { 49 | return Promise; 50 | } 51 | 52 | override get [Symbol.toStringTag]() { 53 | return 'ManualPromise'; 54 | } 55 | } 56 | 57 | export class LongStandingScope { 58 | private _terminateError: Error | undefined; 59 | private _closeError: Error | undefined; 60 | private _terminatePromises = new Map, string[]>(); 61 | private _isClosed = false; 62 | 63 | reject(error: Error) { 64 | this._isClosed = true; 65 | this._terminateError = error; 66 | for (const p of this._terminatePromises.keys()) 67 | p.resolve(error); 68 | } 69 | 70 | close(error: Error) { 71 | this._isClosed = true; 72 | this._closeError = error; 73 | for (const [p, frames] of this._terminatePromises) 74 | p.resolve(cloneError(error, frames)); 75 | } 76 | 77 | isClosed() { 78 | return this._isClosed; 79 | } 80 | 81 | static async raceMultiple(scopes: LongStandingScope[], promise: Promise): Promise { 82 | return Promise.race(scopes.map(s => s.race(promise))); 83 | } 84 | 85 | async race(promise: Promise | Promise[]): Promise { 86 | return this._race(Array.isArray(promise) ? promise : [promise], false) as Promise; 87 | } 88 | 89 | async safeRace(promise: Promise, defaultValue?: T): Promise { 90 | return this._race([promise], true, defaultValue); 91 | } 92 | 93 | private async _race(promises: Promise[], safe: boolean, defaultValue?: any): Promise { 94 | const terminatePromise = new ManualPromise(); 95 | const frames = captureRawStack(); 96 | if (this._terminateError) 97 | terminatePromise.resolve(this._terminateError); 98 | if (this._closeError) 99 | terminatePromise.resolve(cloneError(this._closeError, frames)); 100 | this._terminatePromises.set(terminatePromise, frames); 101 | try { 102 | return await Promise.race([ 103 | terminatePromise.then(e => safe ? defaultValue : Promise.reject(e)), 104 | ...promises 105 | ]); 106 | } finally { 107 | this._terminatePromises.delete(terminatePromise); 108 | } 109 | } 110 | } 111 | 112 | function cloneError(error: Error, frames: string[]) { 113 | const clone = new Error(); 114 | clone.name = error.name; 115 | clone.message = error.message; 116 | clone.stack = [error.name + ':' + error.message, ...frames].join('\n'); 117 | return clone; 118 | } 119 | 120 | function captureRawStack(): string[] { 121 | const stackTraceLimit = Error.stackTraceLimit; 122 | Error.stackTraceLimit = 50; 123 | const error = new Error(); 124 | const stack = error.stack || ''; 125 | Error.stackTraceLimit = stackTraceLimit; 126 | return stack.split('\n'); 127 | } 128 | -------------------------------------------------------------------------------- /src/package.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright (c) Microsoft Corporation. 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | import fs from 'node:fs'; 18 | import url from 'node:url'; 19 | import path from 'node:path'; 20 | 21 | const __filename = url.fileURLToPath(import.meta.url); 22 | export const packageJSON = JSON.parse(fs.readFileSync(path.join(path.dirname(__filename), '..', 'package.json'), 'utf8')); 23 | -------------------------------------------------------------------------------- /src/pageSnapshot.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright (c) Microsoft Corporation. 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | import * as playwright from 'playwright'; 18 | import { callOnPageNoTrace } from './tools/utils.js'; 19 | 20 | type PageEx = playwright.Page & { 21 | _snapshotForAI: () => Promise; 22 | }; 23 | 24 | export class PageSnapshot { 25 | private _page: playwright.Page; 26 | private _text!: string; 27 | 28 | constructor(page: playwright.Page) { 29 | this._page = page; 30 | } 31 | 32 | static async create(page: playwright.Page): Promise { 33 | const snapshot = new PageSnapshot(page); 34 | await snapshot._build(); 35 | return snapshot; 36 | } 37 | 38 | text(): string { 39 | return this._text; 40 | } 41 | 42 | private async _build() { 43 | const snapshot = await callOnPageNoTrace(this._page, page => (page as PageEx)._snapshotForAI()); 44 | this._text = [ 45 | `- Page Snapshot`, 46 | '```yaml', 47 | snapshot, 48 | '```', 49 | ].join('\n'); 50 | } 51 | 52 | refLocator(params: { element: string, ref: string }): playwright.Locator { 53 | return this._page.locator(`aria-ref=${params.ref}`).describe(params.element); 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /src/program.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright (c) Microsoft Corporation. 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | import { program } from 'commander'; 18 | // @ts-ignore 19 | import { startTraceViewerServer } from 'playwright-core/lib/server'; 20 | 21 | import { startHttpTransport, startStdioTransport } from './transport.js'; 22 | import { resolveCLIConfig } from './config.js'; 23 | import { Server } from './server.js'; 24 | import { packageJSON } from './package.js'; 25 | 26 | program 27 | .version('Version ' + packageJSON.version) 28 | .name(packageJSON.name) 29 | .option('--allowed-origins ', 'semicolon-separated list of origins to allow the browser to request. Default is to allow all.', semicolonSeparatedList) 30 | .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) 31 | .option('--block-service-workers', 'block service workers') 32 | .option('--browser ', 'browser or chrome channel to use, possible values: chrome, firefox, webkit, msedge.') 33 | .option('--browser-agent ', 'Use browser agent (experimental).') 34 | .option('--caps ', 'comma-separated list of capabilities to enable, possible values: tabs, pdf, history, wait, files, install. Default is all.') 35 | .option('--cdp-endpoint ', 'CDP endpoint to connect to.') 36 | .option('--config ', 'path to the configuration file.') 37 | .option('--device ', 'device to emulate, for example: "iPhone 15"') 38 | .option('--executable-path ', 'path to the browser executable.') 39 | .option('--headless', 'run browser in headless mode, headed by default') 40 | .option('--host ', 'host to bind server to. Default is localhost. Use 0.0.0.0 to bind to all interfaces.') 41 | .option('--ignore-https-errors', 'ignore https errors') 42 | .option('--isolated', 'keep the browser profile in memory, do not save it to disk.') 43 | .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.') 44 | .option('--no-sandbox', 'disable the sandbox for all process types that are normally sandboxed.') 45 | .option('--output-dir ', 'path to the directory for output files.') 46 | .option('--port ', 'port to listen on for SSE transport.') 47 | .option('--proxy-bypass ', 'comma-separated domains to bypass proxy, for example ".com,chromium.org,.domain.com"') 48 | .option('--proxy-server ', 'specify proxy server, for example "http://myproxy:3128" or "socks5://myproxy:8080"') 49 | .option('--save-trace', 'Whether to save the Playwright Trace of the session into the output directory.') 50 | .option('--storage-state ', 'path to the storage state file for isolated sessions.') 51 | .option('--user-agent ', 'specify user agent string') 52 | .option('--user-data-dir ', 'path to the user data directory. If not specified, a temporary directory will be created.') 53 | .option('--viewport-size ', 'specify browser viewport size in pixels, for example "1280, 720"') 54 | .option('--vision', 'Run server that uses screenshots (Aria snapshots are used by default)') 55 | .action(async options => { 56 | const config = await resolveCLIConfig(options); 57 | const server = new Server(config); 58 | server.setupExitWatchdog(); 59 | 60 | if (config.server.port !== undefined) 61 | startHttpTransport(server); 62 | else 63 | await startStdioTransport(server); 64 | 65 | if (config.saveTrace) { 66 | const server = await startTraceViewerServer(); 67 | const urlPrefix = server.urlPrefix('human-readable'); 68 | const url = urlPrefix + '/trace/index.html?trace=' + config.browser.launchOptions.tracesDir + '/trace.json'; 69 | // eslint-disable-next-line no-console 70 | console.error('\nTrace viewer listening on ' + url); 71 | } 72 | }); 73 | 74 | function semicolonSeparatedList(value: string): string[] { 75 | return value.split(';').map(v => v.trim()); 76 | } 77 | 78 | void program.parseAsync(process.argv); 79 | -------------------------------------------------------------------------------- /src/resources/resource.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright (c) Microsoft Corporation. 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | import type { Context } from '../context.js'; 18 | 19 | export type ResourceSchema = { 20 | uri: string; 21 | name: string; 22 | description?: string; 23 | mimeType?: string; 24 | }; 25 | 26 | export type ResourceResult = { 27 | uri: string; 28 | mimeType?: string; 29 | text?: string; 30 | blob?: string; 31 | }; 32 | 33 | export type Resource = { 34 | schema: ResourceSchema; 35 | read: (context: Context, uri: string) => Promise; 36 | }; 37 | -------------------------------------------------------------------------------- /src/server.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright (c) Microsoft Corporation. 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | import { createConnection } from './connection.js'; 18 | import { contextFactory } from './browserContextFactory.js'; 19 | 20 | import type { FullConfig } from './config.js'; 21 | import type { Connection } from './connection.js'; 22 | import type { Transport } from '@modelcontextprotocol/sdk/shared/transport.js'; 23 | import type { BrowserContextFactory } from './browserContextFactory.js'; 24 | 25 | export class Server { 26 | readonly config: FullConfig; 27 | private _connectionList: Connection[] = []; 28 | private _browserConfig: FullConfig['browser']; 29 | private _contextFactory: BrowserContextFactory; 30 | 31 | constructor(config: FullConfig) { 32 | this.config = config; 33 | this._browserConfig = config.browser; 34 | this._contextFactory = contextFactory(this._browserConfig); 35 | } 36 | 37 | async createConnection(transport: Transport): Promise { 38 | const connection = createConnection(this.config, this._contextFactory); 39 | this._connectionList.push(connection); 40 | await connection.server.connect(transport); 41 | return connection; 42 | } 43 | 44 | setupExitWatchdog() { 45 | let isExiting = false; 46 | const handleExit = async () => { 47 | if (isExiting) 48 | return; 49 | isExiting = true; 50 | setTimeout(() => process.exit(0), 15000); 51 | await Promise.all(this._connectionList.map(connection => connection.close())); 52 | process.exit(0); 53 | }; 54 | 55 | process.stdin.on('close', handleExit); 56 | process.on('SIGINT', handleExit); 57 | process.on('SIGTERM', handleExit); 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /src/tab.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright (c) Microsoft Corporation. 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | import * as playwright from 'playwright'; 18 | 19 | import { PageSnapshot } from './pageSnapshot.js'; 20 | 21 | import type { Context } from './context.js'; 22 | import { callOnPageNoTrace } from './tools/utils.js'; 23 | 24 | export class Tab { 25 | readonly context: Context; 26 | readonly page: playwright.Page; 27 | private _consoleMessages: playwright.ConsoleMessage[] = []; 28 | private _requests: Map = new Map(); 29 | private _snapshot: PageSnapshot | undefined; 30 | private _onPageClose: (tab: Tab) => void; 31 | 32 | constructor(context: Context, page: playwright.Page, onPageClose: (tab: Tab) => void) { 33 | this.context = context; 34 | this.page = page; 35 | this._onPageClose = onPageClose; 36 | page.on('console', event => this._consoleMessages.push(event)); 37 | page.on('request', request => this._requests.set(request, null)); 38 | page.on('response', response => this._requests.set(response.request(), response)); 39 | page.on('close', () => this._onClose()); 40 | page.on('filechooser', chooser => { 41 | this.context.setModalState({ 42 | type: 'fileChooser', 43 | description: 'File chooser', 44 | fileChooser: chooser, 45 | }, this); 46 | }); 47 | page.on('dialog', dialog => this.context.dialogShown(this, dialog)); 48 | page.on('download', download => { 49 | void this.context.downloadStarted(this, download); 50 | }); 51 | page.setDefaultNavigationTimeout(60000); 52 | page.setDefaultTimeout(5000); 53 | } 54 | 55 | private _clearCollectedArtifacts() { 56 | this._consoleMessages.length = 0; 57 | this._requests.clear(); 58 | } 59 | 60 | private _onClose() { 61 | this._clearCollectedArtifacts(); 62 | this._onPageClose(this); 63 | } 64 | 65 | async title(): Promise { 66 | return await callOnPageNoTrace(this.page, page => page.title()); 67 | } 68 | 69 | async waitForLoadState(state: 'load', options?: { timeout?: number }): Promise { 70 | await callOnPageNoTrace(this.page, page => page.waitForLoadState(state, options).catch(() => {})); 71 | } 72 | 73 | async navigate(url: string) { 74 | this._clearCollectedArtifacts(); 75 | 76 | const downloadEvent = callOnPageNoTrace(this.page, page => page.waitForEvent('download').catch(() => {})); 77 | try { 78 | await this.page.goto(url, { waitUntil: 'domcontentloaded' }); 79 | } catch (_e: unknown) { 80 | const e = _e as Error; 81 | const mightBeDownload = 82 | e.message.includes('net::ERR_ABORTED') // chromium 83 | || e.message.includes('Download is starting'); // firefox + webkit 84 | if (!mightBeDownload) 85 | throw e; 86 | 87 | // on chromium, the download event is fired *after* page.goto rejects, so we wait a lil bit 88 | const download = await Promise.race([ 89 | downloadEvent, 90 | new Promise(resolve => setTimeout(resolve, 500)), 91 | ]); 92 | if (!download) 93 | throw e; 94 | } 95 | 96 | // Cap load event to 5 seconds, the page is operational at this point. 97 | await this.waitForLoadState('load', { timeout: 5000 }); 98 | } 99 | 100 | hasSnapshot(): boolean { 101 | return !!this._snapshot; 102 | } 103 | 104 | snapshotOrDie(): PageSnapshot { 105 | if (!this._snapshot) 106 | throw new Error('No snapshot available'); 107 | return this._snapshot; 108 | } 109 | 110 | consoleMessages(): playwright.ConsoleMessage[] { 111 | return this._consoleMessages; 112 | } 113 | 114 | requests(): Map { 115 | return this._requests; 116 | } 117 | 118 | async captureSnapshot() { 119 | this._snapshot = await PageSnapshot.create(this.page); 120 | } 121 | } 122 | -------------------------------------------------------------------------------- /src/tools.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright (c) Microsoft Corporation. 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | import common from './tools/common.js'; 18 | import console from './tools/console.js'; 19 | import dialogs from './tools/dialogs.js'; 20 | import files from './tools/files.js'; 21 | import install from './tools/install.js'; 22 | import keyboard from './tools/keyboard.js'; 23 | import navigate from './tools/navigate.js'; 24 | import network from './tools/network.js'; 25 | import pdf from './tools/pdf.js'; 26 | import snapshot from './tools/snapshot.js'; 27 | import tabs from './tools/tabs.js'; 28 | import screenshot from './tools/screenshot.js'; 29 | import testing from './tools/testing.js'; 30 | import vision from './tools/vision.js'; 31 | import wait from './tools/wait.js'; 32 | 33 | import type { Tool } from './tools/tool.js'; 34 | 35 | export const snapshotTools: Tool[] = [ 36 | ...common(true), 37 | ...console, 38 | ...dialogs(true), 39 | ...files(true), 40 | ...install, 41 | ...keyboard(true), 42 | ...navigate(true), 43 | ...network, 44 | ...pdf, 45 | ...screenshot, 46 | ...snapshot, 47 | ...tabs(true), 48 | ...testing, 49 | ...wait(true), 50 | ]; 51 | 52 | export const visionTools: Tool[] = [ 53 | ...common(false), 54 | ...console, 55 | ...dialogs(false), 56 | ...files(false), 57 | ...install, 58 | ...keyboard(false), 59 | ...navigate(false), 60 | ...network, 61 | ...pdf, 62 | ...tabs(false), 63 | ...testing, 64 | ...vision, 65 | ...wait(false), 66 | ]; 67 | -------------------------------------------------------------------------------- /src/tools/common.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright (c) Microsoft Corporation. 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | import { z } from 'zod'; 18 | import { defineTool, type ToolFactory } from './tool.js'; 19 | 20 | const close = defineTool({ 21 | capability: 'core', 22 | 23 | schema: { 24 | name: 'browser_close', 25 | title: 'Close browser', 26 | description: 'Close the page', 27 | inputSchema: z.object({}), 28 | type: 'readOnly', 29 | }, 30 | 31 | handle: async context => { 32 | await context.close(); 33 | return { 34 | code: [`await page.close()`], 35 | captureSnapshot: false, 36 | waitForNetwork: false, 37 | }; 38 | }, 39 | }); 40 | 41 | const resize: ToolFactory = captureSnapshot => defineTool({ 42 | capability: 'core', 43 | schema: { 44 | name: 'browser_resize', 45 | title: 'Resize browser window', 46 | description: 'Resize the browser window', 47 | inputSchema: z.object({ 48 | width: z.number().describe('Width of the browser window'), 49 | height: z.number().describe('Height of the browser window'), 50 | }), 51 | type: 'readOnly', 52 | }, 53 | 54 | handle: async (context, params) => { 55 | const tab = context.currentTabOrDie(); 56 | 57 | const code = [ 58 | `// Resize browser window to ${params.width}x${params.height}`, 59 | `await page.setViewportSize({ width: ${params.width}, height: ${params.height} });` 60 | ]; 61 | 62 | const action = async () => { 63 | await tab.page.setViewportSize({ width: params.width, height: params.height }); 64 | }; 65 | 66 | return { 67 | code, 68 | action, 69 | captureSnapshot, 70 | waitForNetwork: true 71 | }; 72 | }, 73 | }); 74 | 75 | export default (captureSnapshot: boolean) => [ 76 | close, 77 | resize(captureSnapshot) 78 | ]; 79 | -------------------------------------------------------------------------------- /src/tools/console.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright (c) Microsoft Corporation. 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | import { z } from 'zod'; 18 | import { defineTool } from './tool.js'; 19 | 20 | const console = defineTool({ 21 | capability: 'core', 22 | schema: { 23 | name: 'browser_console_messages', 24 | title: 'Get console messages', 25 | description: 'Returns all console messages', 26 | inputSchema: z.object({}), 27 | type: 'readOnly', 28 | }, 29 | handle: async context => { 30 | const messages = context.currentTabOrDie().consoleMessages(); 31 | const log = messages.map(message => `[${message.type().toUpperCase()}] ${message.text()}`).join('\n'); 32 | return { 33 | code: [`// `], 34 | action: async () => { 35 | return { 36 | content: [{ type: 'text', text: log }] 37 | }; 38 | }, 39 | captureSnapshot: false, 40 | waitForNetwork: false, 41 | }; 42 | }, 43 | }); 44 | 45 | export default [ 46 | console, 47 | ]; 48 | -------------------------------------------------------------------------------- /src/tools/dialogs.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright (c) Microsoft Corporation. 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | import { z } from 'zod'; 18 | import { defineTool, type ToolFactory } from './tool.js'; 19 | 20 | const handleDialog: ToolFactory = captureSnapshot => defineTool({ 21 | capability: 'core', 22 | 23 | schema: { 24 | name: 'browser_handle_dialog', 25 | title: 'Handle a dialog', 26 | description: 'Handle a dialog', 27 | inputSchema: z.object({ 28 | accept: z.boolean().describe('Whether to accept the dialog.'), 29 | promptText: z.string().optional().describe('The text of the prompt in case of a prompt dialog.'), 30 | }), 31 | type: 'destructive', 32 | }, 33 | 34 | handle: async (context, params) => { 35 | const dialogState = context.modalStates().find(state => state.type === 'dialog'); 36 | if (!dialogState) 37 | throw new Error('No dialog visible'); 38 | 39 | if (params.accept) 40 | await dialogState.dialog.accept(params.promptText); 41 | else 42 | await dialogState.dialog.dismiss(); 43 | 44 | context.clearModalState(dialogState); 45 | 46 | const code = [ 47 | `// `, 48 | ]; 49 | 50 | return { 51 | code, 52 | captureSnapshot, 53 | waitForNetwork: false, 54 | }; 55 | }, 56 | 57 | clearsModalState: 'dialog', 58 | }); 59 | 60 | export default (captureSnapshot: boolean) => [ 61 | handleDialog(captureSnapshot), 62 | ]; 63 | -------------------------------------------------------------------------------- /src/tools/files.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright (c) Microsoft Corporation. 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | import { z } from 'zod'; 18 | import { defineTool, type ToolFactory } from './tool.js'; 19 | 20 | const uploadFile: ToolFactory = captureSnapshot => defineTool({ 21 | capability: 'files', 22 | 23 | schema: { 24 | name: 'browser_file_upload', 25 | title: 'Upload files', 26 | description: 'Upload one or multiple files', 27 | inputSchema: z.object({ 28 | paths: z.array(z.string()).describe('The absolute paths to the files to upload. Can be a single file or multiple files.'), 29 | }), 30 | type: 'destructive', 31 | }, 32 | 33 | handle: async (context, params) => { 34 | const modalState = context.modalStates().find(state => state.type === 'fileChooser'); 35 | if (!modalState) 36 | throw new Error('No file chooser visible'); 37 | 38 | const code = [ 39 | `// { 43 | await modalState.fileChooser.setFiles(params.paths); 44 | context.clearModalState(modalState); 45 | }; 46 | 47 | return { 48 | code, 49 | action, 50 | captureSnapshot, 51 | waitForNetwork: true, 52 | }; 53 | }, 54 | clearsModalState: 'fileChooser', 55 | }); 56 | 57 | export default (captureSnapshot: boolean) => [ 58 | uploadFile(captureSnapshot), 59 | ]; 60 | -------------------------------------------------------------------------------- /src/tools/install.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright (c) Microsoft Corporation. 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | import { fork } from 'child_process'; 18 | import path from 'path'; 19 | 20 | import { z } from 'zod'; 21 | import { defineTool } from './tool.js'; 22 | 23 | import { fileURLToPath } from 'node:url'; 24 | 25 | const install = defineTool({ 26 | capability: 'install', 27 | schema: { 28 | name: 'browser_install', 29 | title: 'Install the browser specified in the config', 30 | description: 'Install the browser specified in the config. Call this if you get an error about the browser not being installed.', 31 | inputSchema: z.object({}), 32 | type: 'destructive', 33 | }, 34 | 35 | handle: async context => { 36 | const channel = context.config.browser?.launchOptions?.channel ?? context.config.browser?.browserName ?? 'chrome'; 37 | const cliUrl = import.meta.resolve('playwright/package.json'); 38 | const cliPath = path.join(fileURLToPath(cliUrl), '..', 'cli.js'); 39 | const child = fork(cliPath, ['install', channel], { 40 | stdio: 'pipe', 41 | }); 42 | const output: string[] = []; 43 | child.stdout?.on('data', data => output.push(data.toString())); 44 | child.stderr?.on('data', data => output.push(data.toString())); 45 | await new Promise((resolve, reject) => { 46 | child.on('close', code => { 47 | if (code === 0) 48 | resolve(); 49 | else 50 | reject(new Error(`Failed to install browser: ${output.join('')}`)); 51 | }); 52 | }); 53 | return { 54 | code: [`// Browser ${channel} installed`], 55 | captureSnapshot: false, 56 | waitForNetwork: false, 57 | }; 58 | }, 59 | }); 60 | 61 | export default [ 62 | install, 63 | ]; 64 | -------------------------------------------------------------------------------- /src/tools/keyboard.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright (c) Microsoft Corporation. 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | import { z } from 'zod'; 18 | import { defineTool, type ToolFactory } from './tool.js'; 19 | 20 | const pressKey: ToolFactory = captureSnapshot => defineTool({ 21 | capability: 'core', 22 | 23 | schema: { 24 | name: 'browser_press_key', 25 | title: 'Press a key', 26 | description: 'Press a key on the keyboard', 27 | inputSchema: z.object({ 28 | key: z.string().describe('Name of the key to press or a character to generate, such as `ArrowLeft` or `a`'), 29 | }), 30 | type: 'destructive', 31 | }, 32 | 33 | handle: async (context, params) => { 34 | const tab = context.currentTabOrDie(); 35 | 36 | const code = [ 37 | `// Press ${params.key}`, 38 | `await page.keyboard.press('${params.key}');`, 39 | ]; 40 | 41 | const action = () => tab.page.keyboard.press(params.key); 42 | 43 | return { 44 | code, 45 | action, 46 | captureSnapshot, 47 | waitForNetwork: true 48 | }; 49 | }, 50 | }); 51 | 52 | export default (captureSnapshot: boolean) => [ 53 | pressKey(captureSnapshot), 54 | ]; 55 | -------------------------------------------------------------------------------- /src/tools/navigate.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright (c) Microsoft Corporation. 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | import { z } from 'zod'; 18 | import { defineTool, type ToolFactory } from './tool.js'; 19 | 20 | const navigate: ToolFactory = captureSnapshot => defineTool({ 21 | capability: 'core', 22 | 23 | schema: { 24 | name: 'browser_navigate', 25 | title: 'Navigate to a URL', 26 | description: 'Navigate to a URL', 27 | inputSchema: z.object({ 28 | url: z.string().describe('The URL to navigate to'), 29 | }), 30 | type: 'destructive', 31 | }, 32 | 33 | handle: async (context, params) => { 34 | const tab = await context.ensureTab(); 35 | await tab.navigate(params.url); 36 | 37 | const code = [ 38 | `// Navigate to ${params.url}`, 39 | `await page.goto('${params.url}');`, 40 | ]; 41 | 42 | return { 43 | code, 44 | captureSnapshot, 45 | waitForNetwork: false, 46 | }; 47 | }, 48 | }); 49 | 50 | const goBack: ToolFactory = captureSnapshot => defineTool({ 51 | capability: 'history', 52 | schema: { 53 | name: 'browser_navigate_back', 54 | title: 'Go back', 55 | description: 'Go back to the previous page', 56 | inputSchema: z.object({}), 57 | type: 'readOnly', 58 | }, 59 | 60 | handle: async context => { 61 | const tab = await context.ensureTab(); 62 | await tab.page.goBack(); 63 | const code = [ 64 | `// Navigate back`, 65 | `await page.goBack();`, 66 | ]; 67 | 68 | return { 69 | code, 70 | captureSnapshot, 71 | waitForNetwork: false, 72 | }; 73 | }, 74 | }); 75 | 76 | const goForward: ToolFactory = captureSnapshot => defineTool({ 77 | capability: 'history', 78 | schema: { 79 | name: 'browser_navigate_forward', 80 | title: 'Go forward', 81 | description: 'Go forward to the next page', 82 | inputSchema: z.object({}), 83 | type: 'readOnly', 84 | }, 85 | handle: async context => { 86 | const tab = context.currentTabOrDie(); 87 | await tab.page.goForward(); 88 | const code = [ 89 | `// Navigate forward`, 90 | `await page.goForward();`, 91 | ]; 92 | return { 93 | code, 94 | captureSnapshot, 95 | waitForNetwork: false, 96 | }; 97 | }, 98 | }); 99 | 100 | export default (captureSnapshot: boolean) => [ 101 | navigate(captureSnapshot), 102 | goBack(captureSnapshot), 103 | goForward(captureSnapshot), 104 | ]; 105 | -------------------------------------------------------------------------------- /src/tools/network.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright (c) Microsoft Corporation. 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | import { z } from 'zod'; 18 | import { defineTool } from './tool.js'; 19 | 20 | import type * as playwright from 'playwright'; 21 | 22 | const requests = defineTool({ 23 | capability: 'core', 24 | 25 | schema: { 26 | name: 'browser_network_requests', 27 | title: 'List network requests', 28 | description: 'Returns all network requests since loading the page', 29 | inputSchema: z.object({}), 30 | type: 'readOnly', 31 | }, 32 | 33 | handle: async context => { 34 | const requests = context.currentTabOrDie().requests(); 35 | const log = [...requests.entries()].map(([request, response]) => renderRequest(request, response)).join('\n'); 36 | return { 37 | code: [`// `], 38 | action: async () => { 39 | return { 40 | content: [{ type: 'text', text: log }] 41 | }; 42 | }, 43 | captureSnapshot: false, 44 | waitForNetwork: false, 45 | }; 46 | }, 47 | }); 48 | 49 | function renderRequest(request: playwright.Request, response: playwright.Response | null) { 50 | const result: string[] = []; 51 | result.push(`[${request.method().toUpperCase()}] ${request.url()}`); 52 | if (response) 53 | result.push(`=> [${response.status()}] ${response.statusText()}`); 54 | return result.join(' '); 55 | } 56 | 57 | export default [ 58 | requests, 59 | ]; 60 | -------------------------------------------------------------------------------- /src/tools/pdf.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright (c) Microsoft Corporation. 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | import { z } from 'zod'; 18 | import { defineTool } from './tool.js'; 19 | 20 | import * as javascript from '../javascript.js'; 21 | import { outputFile } from '../config.js'; 22 | 23 | const pdfSchema = z.object({ 24 | filename: z.string().optional().describe('File name to save the pdf to. Defaults to `page-{timestamp}.pdf` if not specified.'), 25 | }); 26 | 27 | const pdf = defineTool({ 28 | capability: 'pdf', 29 | 30 | schema: { 31 | name: 'browser_pdf_save', 32 | title: 'Save as PDF', 33 | description: 'Save page as PDF', 34 | inputSchema: pdfSchema, 35 | type: 'readOnly', 36 | }, 37 | 38 | handle: async (context, params) => { 39 | const tab = context.currentTabOrDie(); 40 | const fileName = await outputFile(context.config, params.filename ?? `page-${new Date().toISOString()}.pdf`); 41 | 42 | const code = [ 43 | `// Save page as ${fileName}`, 44 | `await page.pdf(${javascript.formatObject({ path: fileName })});`, 45 | ]; 46 | 47 | return { 48 | code, 49 | action: async () => tab.page.pdf({ path: fileName }).then(() => {}), 50 | captureSnapshot: false, 51 | waitForNetwork: false, 52 | }; 53 | }, 54 | }); 55 | 56 | export default [ 57 | pdf, 58 | ]; 59 | -------------------------------------------------------------------------------- /src/tools/screenshot.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright (c) Microsoft Corporation. 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | import { z } from 'zod'; 18 | 19 | import { defineTool } from './tool.js'; 20 | import * as javascript from '../javascript.js'; 21 | import { outputFile } from '../config.js'; 22 | import { generateLocator } from './utils.js'; 23 | 24 | import type * as playwright from 'playwright'; 25 | 26 | const screenshotSchema = z.object({ 27 | raw: z.boolean().optional().describe('Whether to return without compression (in PNG format). Default is false, which returns a JPEG image.'), 28 | filename: z.string().optional().describe('File name to save the screenshot to. Defaults to `page-{timestamp}.{png|jpeg}` if not specified.'), 29 | 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.'), 30 | 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.'), 31 | }).refine(data => { 32 | return !!data.element === !!data.ref; 33 | }, { 34 | message: 'Both element and ref must be provided or neither.', 35 | path: ['ref', 'element'] 36 | }); 37 | 38 | const screenshot = defineTool({ 39 | capability: 'core', 40 | schema: { 41 | name: 'browser_take_screenshot', 42 | title: 'Take a screenshot', 43 | description: `Take a screenshot of the current page. You can't perform actions based on the screenshot, use browser_snapshot for actions.`, 44 | inputSchema: screenshotSchema, 45 | type: 'readOnly', 46 | }, 47 | 48 | handle: async (context, params) => { 49 | const tab = context.currentTabOrDie(); 50 | const snapshot = tab.snapshotOrDie(); 51 | const fileType = params.raw ? 'png' : 'jpeg'; 52 | const fileName = await outputFile(context.config, params.filename ?? `page-${new Date().toISOString()}.${fileType}`); 53 | const options: playwright.PageScreenshotOptions = { type: fileType, quality: fileType === 'png' ? undefined : 50, scale: 'css', path: fileName }; 54 | const isElementScreenshot = params.element && params.ref; 55 | 56 | const code = [ 57 | `// Screenshot ${isElementScreenshot ? params.element : 'viewport'} and save it as ${fileName}`, 58 | ]; 59 | 60 | const locator = params.ref ? snapshot.refLocator({ element: params.element || '', ref: params.ref }) : null; 61 | 62 | if (locator) 63 | code.push(`await page.${await generateLocator(locator)}.screenshot(${javascript.formatObject(options)});`); 64 | else 65 | code.push(`await page.screenshot(${javascript.formatObject(options)});`); 66 | 67 | const includeBase64 = context.clientSupportsImages(); 68 | const action = async () => { 69 | const screenshot = locator ? await locator.screenshot(options) : await tab.page.screenshot(options); 70 | return { 71 | content: includeBase64 ? [{ 72 | type: 'image' as 'image', 73 | data: screenshot.toString('base64'), 74 | mimeType: fileType === 'png' ? 'image/png' : 'image/jpeg', 75 | }] : [] 76 | }; 77 | }; 78 | 79 | return { 80 | code, 81 | action, 82 | captureSnapshot: true, 83 | waitForNetwork: false, 84 | }; 85 | } 86 | }); 87 | 88 | export default [ 89 | screenshot, 90 | ]; 91 | -------------------------------------------------------------------------------- /src/tools/snapshot.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright (c) Microsoft Corporation. 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | import { z } from 'zod'; 18 | 19 | import { defineTool } from './tool.js'; 20 | import * as javascript from '../javascript.js'; 21 | import { generateLocator } from './utils.js'; 22 | 23 | const snapshot = defineTool({ 24 | capability: 'core', 25 | schema: { 26 | name: 'browser_snapshot', 27 | title: 'Page snapshot', 28 | description: 'Capture accessibility snapshot of the current page, this is better than screenshot', 29 | inputSchema: z.object({}), 30 | type: 'readOnly', 31 | }, 32 | 33 | handle: async context => { 34 | await context.ensureTab(); 35 | 36 | return { 37 | code: [`// `], 38 | captureSnapshot: true, 39 | waitForNetwork: false, 40 | }; 41 | }, 42 | }); 43 | 44 | const elementSchema = z.object({ 45 | element: z.string().describe('Human-readable element description used to obtain permission to interact with the element'), 46 | ref: z.string().describe('Exact target element reference from the page snapshot'), 47 | }); 48 | 49 | const click = defineTool({ 50 | capability: 'core', 51 | schema: { 52 | name: 'browser_click', 53 | title: 'Click', 54 | description: 'Perform click on a web page', 55 | inputSchema: elementSchema, 56 | type: 'destructive', 57 | }, 58 | 59 | handle: async (context, params) => { 60 | const tab = context.currentTabOrDie(); 61 | const locator = tab.snapshotOrDie().refLocator(params); 62 | 63 | const code = [ 64 | `// Click ${params.element}`, 65 | `await page.${await generateLocator(locator)}.click();` 66 | ]; 67 | 68 | return { 69 | code, 70 | action: () => locator.click(), 71 | captureSnapshot: true, 72 | waitForNetwork: true, 73 | }; 74 | }, 75 | }); 76 | 77 | const drag = defineTool({ 78 | capability: 'core', 79 | schema: { 80 | name: 'browser_drag', 81 | title: 'Drag mouse', 82 | description: 'Perform drag and drop between two elements', 83 | inputSchema: z.object({ 84 | startElement: z.string().describe('Human-readable source element description used to obtain the permission to interact with the element'), 85 | startRef: z.string().describe('Exact source element reference from the page snapshot'), 86 | endElement: z.string().describe('Human-readable target element description used to obtain the permission to interact with the element'), 87 | endRef: z.string().describe('Exact target element reference from the page snapshot'), 88 | }), 89 | type: 'destructive', 90 | }, 91 | 92 | handle: async (context, params) => { 93 | const snapshot = context.currentTabOrDie().snapshotOrDie(); 94 | const startLocator = snapshot.refLocator({ ref: params.startRef, element: params.startElement }); 95 | const endLocator = snapshot.refLocator({ ref: params.endRef, element: params.endElement }); 96 | 97 | const code = [ 98 | `// Drag ${params.startElement} to ${params.endElement}`, 99 | `await page.${await generateLocator(startLocator)}.dragTo(page.${await generateLocator(endLocator)});` 100 | ]; 101 | 102 | return { 103 | code, 104 | action: () => startLocator.dragTo(endLocator), 105 | captureSnapshot: true, 106 | waitForNetwork: true, 107 | }; 108 | }, 109 | }); 110 | 111 | const hover = defineTool({ 112 | capability: 'core', 113 | schema: { 114 | name: 'browser_hover', 115 | title: 'Hover mouse', 116 | description: 'Hover over element on page', 117 | inputSchema: elementSchema, 118 | type: 'readOnly', 119 | }, 120 | 121 | handle: async (context, params) => { 122 | const snapshot = context.currentTabOrDie().snapshotOrDie(); 123 | const locator = snapshot.refLocator(params); 124 | 125 | const code = [ 126 | `// Hover over ${params.element}`, 127 | `await page.${await generateLocator(locator)}.hover();` 128 | ]; 129 | 130 | return { 131 | code, 132 | action: () => locator.hover(), 133 | captureSnapshot: true, 134 | waitForNetwork: true, 135 | }; 136 | }, 137 | }); 138 | 139 | const typeSchema = elementSchema.extend({ 140 | text: z.string().describe('Text to type into the element'), 141 | submit: z.boolean().optional().describe('Whether to submit entered text (press Enter after)'), 142 | 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.'), 143 | }); 144 | 145 | const type = defineTool({ 146 | capability: 'core', 147 | schema: { 148 | name: 'browser_type', 149 | title: 'Type text', 150 | description: 'Type text into editable element', 151 | inputSchema: typeSchema, 152 | type: 'destructive', 153 | }, 154 | 155 | handle: async (context, params) => { 156 | const snapshot = context.currentTabOrDie().snapshotOrDie(); 157 | const locator = snapshot.refLocator(params); 158 | 159 | const code: string[] = []; 160 | const steps: (() => Promise)[] = []; 161 | 162 | if (params.slowly) { 163 | code.push(`// Press "${params.text}" sequentially into "${params.element}"`); 164 | code.push(`await page.${await generateLocator(locator)}.pressSequentially(${javascript.quote(params.text)});`); 165 | steps.push(() => locator.pressSequentially(params.text)); 166 | } else { 167 | code.push(`// Fill "${params.text}" into "${params.element}"`); 168 | code.push(`await page.${await generateLocator(locator)}.fill(${javascript.quote(params.text)});`); 169 | steps.push(() => locator.fill(params.text)); 170 | } 171 | 172 | if (params.submit) { 173 | code.push(`// Submit text`); 174 | code.push(`await page.${await generateLocator(locator)}.press('Enter');`); 175 | steps.push(() => locator.press('Enter')); 176 | } 177 | 178 | return { 179 | code, 180 | action: () => steps.reduce((acc, step) => acc.then(step), Promise.resolve()), 181 | captureSnapshot: true, 182 | waitForNetwork: true, 183 | }; 184 | }, 185 | }); 186 | 187 | const selectOptionSchema = elementSchema.extend({ 188 | values: z.array(z.string()).describe('Array of values to select in the dropdown. This can be a single value or multiple values.'), 189 | }); 190 | 191 | const selectOption = defineTool({ 192 | capability: 'core', 193 | schema: { 194 | name: 'browser_select_option', 195 | title: 'Select option', 196 | description: 'Select an option in a dropdown', 197 | inputSchema: selectOptionSchema, 198 | type: 'destructive', 199 | }, 200 | 201 | handle: async (context, params) => { 202 | const snapshot = context.currentTabOrDie().snapshotOrDie(); 203 | const locator = snapshot.refLocator(params); 204 | 205 | const code = [ 206 | `// Select options [${params.values.join(', ')}] in ${params.element}`, 207 | `await page.${await generateLocator(locator)}.selectOption(${javascript.formatObject(params.values)});` 208 | ]; 209 | 210 | return { 211 | code, 212 | action: () => locator.selectOption(params.values).then(() => {}), 213 | captureSnapshot: true, 214 | waitForNetwork: true, 215 | }; 216 | }, 217 | }); 218 | 219 | export default [ 220 | snapshot, 221 | click, 222 | drag, 223 | hover, 224 | type, 225 | selectOption, 226 | ]; 227 | -------------------------------------------------------------------------------- /src/tools/tabs.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright (c) Microsoft Corporation. 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | import { z } from 'zod'; 18 | import { defineTool, type ToolFactory } from './tool.js'; 19 | 20 | const listTabs = defineTool({ 21 | capability: 'tabs', 22 | 23 | schema: { 24 | name: 'browser_tab_list', 25 | title: 'List tabs', 26 | description: 'List browser tabs', 27 | inputSchema: z.object({}), 28 | type: 'readOnly', 29 | }, 30 | 31 | handle: async context => { 32 | await context.ensureTab(); 33 | return { 34 | code: [`// `], 35 | captureSnapshot: false, 36 | waitForNetwork: false, 37 | resultOverride: { 38 | content: [{ 39 | type: 'text', 40 | text: await context.listTabsMarkdown(), 41 | }], 42 | }, 43 | }; 44 | }, 45 | }); 46 | 47 | const selectTab: ToolFactory = captureSnapshot => defineTool({ 48 | capability: 'tabs', 49 | 50 | schema: { 51 | name: 'browser_tab_select', 52 | title: 'Select a tab', 53 | description: 'Select a tab by index', 54 | inputSchema: z.object({ 55 | index: z.number().describe('The index of the tab to select'), 56 | }), 57 | type: 'readOnly', 58 | }, 59 | 60 | handle: async (context, params) => { 61 | await context.selectTab(params.index); 62 | const code = [ 63 | `// `, 64 | ]; 65 | 66 | return { 67 | code, 68 | captureSnapshot, 69 | waitForNetwork: false 70 | }; 71 | }, 72 | }); 73 | 74 | const newTab: ToolFactory = captureSnapshot => defineTool({ 75 | capability: 'tabs', 76 | 77 | schema: { 78 | name: 'browser_tab_new', 79 | title: 'Open a new tab', 80 | description: 'Open a new tab', 81 | inputSchema: z.object({ 82 | url: z.string().optional().describe('The URL to navigate to in the new tab. If not provided, the new tab will be blank.'), 83 | }), 84 | type: 'readOnly', 85 | }, 86 | 87 | handle: async (context, params) => { 88 | await context.newTab(); 89 | if (params.url) 90 | await context.currentTabOrDie().navigate(params.url); 91 | 92 | const code = [ 93 | `// `, 94 | ]; 95 | return { 96 | code, 97 | captureSnapshot, 98 | waitForNetwork: false 99 | }; 100 | }, 101 | }); 102 | 103 | const closeTab: ToolFactory = captureSnapshot => defineTool({ 104 | capability: 'tabs', 105 | 106 | schema: { 107 | name: 'browser_tab_close', 108 | title: 'Close a tab', 109 | description: 'Close a tab', 110 | inputSchema: z.object({ 111 | index: z.number().optional().describe('The index of the tab to close. Closes current tab if not provided.'), 112 | }), 113 | type: 'destructive', 114 | }, 115 | 116 | handle: async (context, params) => { 117 | await context.closeTab(params.index); 118 | const code = [ 119 | `// `, 120 | ]; 121 | return { 122 | code, 123 | captureSnapshot, 124 | waitForNetwork: false 125 | }; 126 | }, 127 | }); 128 | 129 | export default (captureSnapshot: boolean) => [ 130 | listTabs, 131 | newTab(captureSnapshot), 132 | selectTab(captureSnapshot), 133 | closeTab(captureSnapshot), 134 | ]; 135 | -------------------------------------------------------------------------------- /src/tools/testing.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright (c) Microsoft Corporation. 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | import { z } from 'zod'; 18 | import { defineTool } from './tool.js'; 19 | 20 | const generateTestSchema = z.object({ 21 | name: z.string().describe('The name of the test'), 22 | description: z.string().describe('The description of the test'), 23 | steps: z.array(z.string()).describe('The steps of the test'), 24 | }); 25 | 26 | const generateTest = defineTool({ 27 | capability: 'testing', 28 | 29 | schema: { 30 | name: 'browser_generate_playwright_test', 31 | title: 'Generate a Playwright test', 32 | description: 'Generate a Playwright test for given scenario', 33 | inputSchema: generateTestSchema, 34 | type: 'readOnly', 35 | }, 36 | 37 | handle: async (context, params) => { 38 | return { 39 | resultOverride: { 40 | content: [{ 41 | type: 'text', 42 | text: instructions(params), 43 | }], 44 | }, 45 | code: [], 46 | captureSnapshot: false, 47 | waitForNetwork: false, 48 | }; 49 | }, 50 | }); 51 | 52 | const instructions = (params: { name: string, description: string, steps: string[] }) => [ 53 | `## Instructions`, 54 | `- You are a playwright test generator.`, 55 | `- You are given a scenario and you need to generate a playwright test for it.`, 56 | '- DO NOT generate test code based on the scenario alone. DO run steps one by one using the tools provided instead.', 57 | '- Only after all steps are completed, emit a Playwright TypeScript test that uses @playwright/test based on message history', 58 | '- Save generated test file in the tests directory', 59 | `Test name: ${params.name}`, 60 | `Description: ${params.description}`, 61 | `Steps:`, 62 | ...params.steps.map((step, index) => `- ${index + 1}. ${step}`), 63 | ].join('\n'); 64 | 65 | export default [ 66 | generateTest, 67 | ]; 68 | -------------------------------------------------------------------------------- /src/tools/tool.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright (c) Microsoft Corporation. 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | import type { ImageContent, TextContent } from '@modelcontextprotocol/sdk/types.js'; 18 | import type { z } from 'zod'; 19 | import type { Context } from '../context.js'; 20 | import type * as playwright from 'playwright'; 21 | import type { ToolCapability } from '../../config.js'; 22 | 23 | export type ToolSchema = { 24 | name: string; 25 | title: string; 26 | description: string; 27 | inputSchema: Input; 28 | type: 'readOnly' | 'destructive'; 29 | }; 30 | 31 | type InputType = z.Schema; 32 | 33 | export type FileUploadModalState = { 34 | type: 'fileChooser'; 35 | description: string; 36 | fileChooser: playwright.FileChooser; 37 | }; 38 | 39 | export type DialogModalState = { 40 | type: 'dialog'; 41 | description: string; 42 | dialog: playwright.Dialog; 43 | }; 44 | 45 | export type ModalState = FileUploadModalState | DialogModalState; 46 | 47 | export type ToolActionResult = { content?: (ImageContent | TextContent)[] } | undefined | void; 48 | 49 | export type ToolResult = { 50 | code: string[]; 51 | action?: () => Promise; 52 | captureSnapshot: boolean; 53 | waitForNetwork: boolean; 54 | resultOverride?: ToolActionResult; 55 | }; 56 | 57 | export type Tool = { 58 | capability: ToolCapability; 59 | schema: ToolSchema; 60 | clearsModalState?: ModalState['type']; 61 | handle: (context: Context, params: z.output) => Promise; 62 | }; 63 | 64 | export type ToolFactory = (snapshot: boolean) => Tool; 65 | 66 | export function defineTool(tool: Tool): Tool { 67 | return tool; 68 | } 69 | -------------------------------------------------------------------------------- /src/tools/utils.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright (c) Microsoft Corporation. 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | import type * as playwright from 'playwright'; 18 | import type { Context } from '../context.js'; 19 | import type { Tab } from '../tab.js'; 20 | 21 | export async function waitForCompletion(context: Context, tab: Tab, callback: () => Promise): Promise { 22 | const requests = new Set(); 23 | let frameNavigated = false; 24 | let waitCallback: () => void = () => {}; 25 | const waitBarrier = new Promise(f => { waitCallback = f; }); 26 | 27 | const requestListener = (request: playwright.Request) => requests.add(request); 28 | const requestFinishedListener = (request: playwright.Request) => { 29 | requests.delete(request); 30 | if (!requests.size) 31 | waitCallback(); 32 | }; 33 | 34 | const frameNavigateListener = (frame: playwright.Frame) => { 35 | if (frame.parentFrame()) 36 | return; 37 | frameNavigated = true; 38 | dispose(); 39 | clearTimeout(timeout); 40 | void tab.waitForLoadState('load').then(waitCallback); 41 | }; 42 | 43 | const onTimeout = () => { 44 | dispose(); 45 | waitCallback(); 46 | }; 47 | 48 | tab.page.on('request', requestListener); 49 | tab.page.on('requestfinished', requestFinishedListener); 50 | tab.page.on('framenavigated', frameNavigateListener); 51 | const timeout = setTimeout(onTimeout, 10000); 52 | 53 | const dispose = () => { 54 | tab.page.off('request', requestListener); 55 | tab.page.off('requestfinished', requestFinishedListener); 56 | tab.page.off('framenavigated', frameNavigateListener); 57 | clearTimeout(timeout); 58 | }; 59 | 60 | try { 61 | const result = await callback(); 62 | if (!requests.size && !frameNavigated) 63 | waitCallback(); 64 | await waitBarrier; 65 | await context.waitForTimeout(1000); 66 | return result; 67 | } finally { 68 | dispose(); 69 | } 70 | } 71 | 72 | export function sanitizeForFilePath(s: string) { 73 | const sanitize = (s: string) => s.replace(/[\x00-\x2C\x2E-\x2F\x3A-\x40\x5B-\x60\x7B-\x7F]+/g, '-'); 74 | const separator = s.lastIndexOf('.'); 75 | if (separator === -1) 76 | return sanitize(s); 77 | return sanitize(s.substring(0, separator)) + '.' + sanitize(s.substring(separator + 1)); 78 | } 79 | 80 | export async function generateLocator(locator: playwright.Locator): Promise { 81 | return (locator as any)._generateLocatorString(); 82 | } 83 | 84 | export async function callOnPageNoTrace(page: playwright.Page, callback: (page: playwright.Page) => Promise): Promise { 85 | return await (page as any)._wrapApiCall(() => callback(page), { internal: true }); 86 | } 87 | -------------------------------------------------------------------------------- /src/tools/vision.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright (c) Microsoft Corporation. 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | import { z } from 'zod'; 18 | import { defineTool } from './tool.js'; 19 | 20 | import * as javascript from '../javascript.js'; 21 | 22 | const elementSchema = z.object({ 23 | element: z.string().describe('Human-readable element description used to obtain permission to interact with the element'), 24 | }); 25 | 26 | const screenshot = defineTool({ 27 | capability: 'core', 28 | schema: { 29 | name: 'browser_screen_capture', 30 | title: 'Take a screenshot', 31 | description: 'Take a screenshot of the current page', 32 | inputSchema: z.object({}), 33 | type: 'readOnly', 34 | }, 35 | 36 | handle: async context => { 37 | const tab = await context.ensureTab(); 38 | const options = { type: 'jpeg' as 'jpeg', quality: 50, scale: 'css' as 'css' }; 39 | 40 | const code = [ 41 | `// Take a screenshot of the current page`, 42 | `await page.screenshot(${javascript.formatObject(options)});`, 43 | ]; 44 | 45 | const action = () => tab.page.screenshot(options).then(buffer => { 46 | return { 47 | content: [{ type: 'image' as 'image', data: buffer.toString('base64'), mimeType: 'image/jpeg' }], 48 | }; 49 | }); 50 | 51 | return { 52 | code, 53 | action, 54 | captureSnapshot: false, 55 | waitForNetwork: false 56 | }; 57 | }, 58 | }); 59 | 60 | const moveMouse = defineTool({ 61 | capability: 'core', 62 | schema: { 63 | name: 'browser_screen_move_mouse', 64 | title: 'Move mouse', 65 | description: 'Move mouse to a given position', 66 | inputSchema: elementSchema.extend({ 67 | x: z.number().describe('X coordinate'), 68 | y: z.number().describe('Y coordinate'), 69 | }), 70 | type: 'readOnly', 71 | }, 72 | 73 | handle: async (context, params) => { 74 | const tab = context.currentTabOrDie(); 75 | const code = [ 76 | `// Move mouse to (${params.x}, ${params.y})`, 77 | `await page.mouse.move(${params.x}, ${params.y});`, 78 | ]; 79 | const action = () => tab.page.mouse.move(params.x, params.y); 80 | return { 81 | code, 82 | action, 83 | captureSnapshot: false, 84 | waitForNetwork: false 85 | }; 86 | }, 87 | }); 88 | 89 | const click = defineTool({ 90 | capability: 'core', 91 | schema: { 92 | name: 'browser_screen_click', 93 | title: 'Click', 94 | description: 'Click left mouse button', 95 | inputSchema: elementSchema.extend({ 96 | x: z.number().describe('X coordinate'), 97 | y: z.number().describe('Y coordinate'), 98 | }), 99 | type: 'destructive', 100 | }, 101 | 102 | handle: async (context, params) => { 103 | const tab = context.currentTabOrDie(); 104 | const code = [ 105 | `// Click mouse at coordinates (${params.x}, ${params.y})`, 106 | `await page.mouse.move(${params.x}, ${params.y});`, 107 | `await page.mouse.down();`, 108 | `await page.mouse.up();`, 109 | ]; 110 | const action = async () => { 111 | await tab.page.mouse.move(params.x, params.y); 112 | await tab.page.mouse.down(); 113 | await tab.page.mouse.up(); 114 | }; 115 | return { 116 | code, 117 | action, 118 | captureSnapshot: false, 119 | waitForNetwork: true, 120 | }; 121 | }, 122 | }); 123 | 124 | const drag = defineTool({ 125 | capability: 'core', 126 | schema: { 127 | name: 'browser_screen_drag', 128 | title: 'Drag mouse', 129 | description: 'Drag left mouse button', 130 | inputSchema: elementSchema.extend({ 131 | startX: z.number().describe('Start X coordinate'), 132 | startY: z.number().describe('Start Y coordinate'), 133 | endX: z.number().describe('End X coordinate'), 134 | endY: z.number().describe('End Y coordinate'), 135 | }), 136 | type: 'destructive', 137 | }, 138 | 139 | handle: async (context, params) => { 140 | const tab = context.currentTabOrDie(); 141 | 142 | const code = [ 143 | `// Drag mouse from (${params.startX}, ${params.startY}) to (${params.endX}, ${params.endY})`, 144 | `await page.mouse.move(${params.startX}, ${params.startY});`, 145 | `await page.mouse.down();`, 146 | `await page.mouse.move(${params.endX}, ${params.endY});`, 147 | `await page.mouse.up();`, 148 | ]; 149 | 150 | const action = async () => { 151 | await tab.page.mouse.move(params.startX, params.startY); 152 | await tab.page.mouse.down(); 153 | await tab.page.mouse.move(params.endX, params.endY); 154 | await tab.page.mouse.up(); 155 | }; 156 | 157 | return { 158 | code, 159 | action, 160 | captureSnapshot: false, 161 | waitForNetwork: true, 162 | }; 163 | }, 164 | }); 165 | 166 | const type = defineTool({ 167 | capability: 'core', 168 | schema: { 169 | name: 'browser_screen_type', 170 | title: 'Type text', 171 | description: 'Type text', 172 | inputSchema: z.object({ 173 | text: z.string().describe('Text to type into the element'), 174 | submit: z.boolean().optional().describe('Whether to submit entered text (press Enter after)'), 175 | }), 176 | type: 'destructive', 177 | }, 178 | 179 | handle: async (context, params) => { 180 | const tab = context.currentTabOrDie(); 181 | 182 | const code = [ 183 | `// Type ${params.text}`, 184 | `await page.keyboard.type('${params.text}');`, 185 | ]; 186 | 187 | const action = async () => { 188 | await tab.page.keyboard.type(params.text); 189 | if (params.submit) 190 | await tab.page.keyboard.press('Enter'); 191 | }; 192 | 193 | if (params.submit) { 194 | code.push(`// Submit text`); 195 | code.push(`await page.keyboard.press('Enter');`); 196 | } 197 | 198 | return { 199 | code, 200 | action, 201 | captureSnapshot: false, 202 | waitForNetwork: true, 203 | }; 204 | }, 205 | }); 206 | 207 | export default [ 208 | screenshot, 209 | moveMouse, 210 | click, 211 | drag, 212 | type, 213 | ]; 214 | -------------------------------------------------------------------------------- /src/tools/wait.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright (c) Microsoft Corporation. 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | import { z } from 'zod'; 18 | import { defineTool, type ToolFactory } from './tool.js'; 19 | 20 | const wait: ToolFactory = captureSnapshot => defineTool({ 21 | capability: 'wait', 22 | 23 | schema: { 24 | name: 'browser_wait_for', 25 | title: 'Wait for', 26 | description: 'Wait for text to appear or disappear or a specified time to pass', 27 | inputSchema: z.object({ 28 | time: z.number().optional().describe('The time to wait in seconds'), 29 | text: z.string().optional().describe('The text to wait for'), 30 | textGone: z.string().optional().describe('The text to wait for to disappear'), 31 | }), 32 | type: 'readOnly', 33 | }, 34 | 35 | handle: async (context, params) => { 36 | if (!params.text && !params.textGone && !params.time) 37 | throw new Error('Either time, text or textGone must be provided'); 38 | 39 | const code: string[] = []; 40 | 41 | if (params.time) { 42 | code.push(`await new Promise(f => setTimeout(f, ${params.time!} * 1000));`); 43 | await new Promise(f => setTimeout(f, Math.min(10000, params.time! * 1000))); 44 | } 45 | 46 | const tab = context.currentTabOrDie(); 47 | const locator = params.text ? tab.page.getByText(params.text).first() : undefined; 48 | const goneLocator = params.textGone ? tab.page.getByText(params.textGone).first() : undefined; 49 | 50 | if (goneLocator) { 51 | code.push(`await page.getByText(${JSON.stringify(params.textGone)}).first().waitFor({ state: 'hidden' });`); 52 | await goneLocator.waitFor({ state: 'hidden' }); 53 | } 54 | 55 | if (locator) { 56 | code.push(`await page.getByText(${JSON.stringify(params.text)}).first().waitFor({ state: 'visible' });`); 57 | await locator.waitFor({ state: 'visible' }); 58 | } 59 | 60 | return { 61 | code, 62 | captureSnapshot, 63 | waitForNetwork: false, 64 | }; 65 | }, 66 | }); 67 | 68 | export default (captureSnapshot: boolean) => [ 69 | wait(captureSnapshot), 70 | ]; 71 | -------------------------------------------------------------------------------- /src/transport.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright (c) Microsoft Corporation. 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | import http from 'node:http'; 18 | import assert from 'node:assert'; 19 | import crypto from 'node:crypto'; 20 | 21 | import debug from 'debug'; 22 | import { SSEServerTransport } from '@modelcontextprotocol/sdk/server/sse.js'; 23 | import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js'; 24 | import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'; 25 | 26 | import type { Server } from './server.js'; 27 | 28 | export async function startStdioTransport(server: Server) { 29 | await server.createConnection(new StdioServerTransport()); 30 | } 31 | 32 | const testDebug = debug('pw:mcp:test'); 33 | 34 | async function handleSSE(server: Server, req: http.IncomingMessage, res: http.ServerResponse, url: URL, sessions: Map) { 35 | if (req.method === 'POST') { 36 | const sessionId = url.searchParams.get('sessionId'); 37 | if (!sessionId) { 38 | res.statusCode = 400; 39 | return res.end('Missing sessionId'); 40 | } 41 | 42 | const transport = sessions.get(sessionId); 43 | if (!transport) { 44 | res.statusCode = 404; 45 | return res.end('Session not found'); 46 | } 47 | 48 | return await transport.handlePostMessage(req, res); 49 | } else if (req.method === 'GET') { 50 | const transport = new SSEServerTransport('/sse', res); 51 | sessions.set(transport.sessionId, transport); 52 | testDebug(`create SSE session: ${transport.sessionId}`); 53 | const connection = await server.createConnection(transport); 54 | res.on('close', () => { 55 | testDebug(`delete SSE session: ${transport.sessionId}`); 56 | sessions.delete(transport.sessionId); 57 | // eslint-disable-next-line no-console 58 | void connection.close().catch(e => console.error(e)); 59 | }); 60 | return; 61 | } 62 | 63 | res.statusCode = 405; 64 | res.end('Method not allowed'); 65 | } 66 | 67 | async function handleStreamable(server: Server, req: http.IncomingMessage, res: http.ServerResponse, sessions: Map) { 68 | const sessionId = req.headers['mcp-session-id'] as string | undefined; 69 | if (sessionId) { 70 | const transport = sessions.get(sessionId); 71 | if (!transport) { 72 | res.statusCode = 404; 73 | res.end('Session not found'); 74 | return; 75 | } 76 | return await transport.handleRequest(req, res); 77 | } 78 | 79 | if (req.method === 'POST') { 80 | const transport = new StreamableHTTPServerTransport({ 81 | sessionIdGenerator: () => crypto.randomUUID(), 82 | onsessioninitialized: sessionId => { 83 | sessions.set(sessionId, transport); 84 | } 85 | }); 86 | transport.onclose = () => { 87 | if (transport.sessionId) 88 | sessions.delete(transport.sessionId); 89 | }; 90 | await server.createConnection(transport); 91 | await transport.handleRequest(req, res); 92 | return; 93 | } 94 | 95 | res.statusCode = 400; 96 | res.end('Invalid request'); 97 | } 98 | 99 | export function startHttpTransport(server: Server) { 100 | const sseSessions = new Map(); 101 | const streamableSessions = new Map(); 102 | const httpServer = http.createServer(async (req, res) => { 103 | const url = new URL(`http://localhost${req.url}`); 104 | if (url.pathname.startsWith('/mcp')) 105 | await handleStreamable(server, req, res, streamableSessions); 106 | else 107 | await handleSSE(server, req, res, url, sseSessions); 108 | }); 109 | const { host, port } = server.config.server; 110 | httpServer.listen(port, host, () => { 111 | const address = httpServer.address(); 112 | assert(address, 'Could not bind server socket'); 113 | let url: string; 114 | if (typeof address === 'string') { 115 | url = address; 116 | } else { 117 | const resolvedPort = address.port; 118 | let resolvedHost = address.family === 'IPv4' ? address.address : `[${address.address}]`; 119 | if (resolvedHost === '0.0.0.0' || resolvedHost === '[::]') 120 | resolvedHost = 'localhost'; 121 | url = `http://${resolvedHost}:${resolvedPort}`; 122 | } 123 | const message = [ 124 | `Listening on ${url}`, 125 | 'Put this in your client config:', 126 | JSON.stringify({ 127 | 'mcpServers': { 128 | 'playwright': { 129 | 'url': `${url}/sse` 130 | } 131 | } 132 | }, undefined, 2), 133 | 'If your client supports streamable HTTP, you can use the /mcp endpoint instead.', 134 | ].join('\n'); 135 | // eslint-disable-next-line no-console 136 | console.error(message); 137 | }); 138 | } 139 | -------------------------------------------------------------------------------- /tests/browser-server.spec.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright (c) Microsoft Corporation. 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | import path from 'path'; 17 | import url from 'node:url'; 18 | 19 | import { spawn } from 'child_process'; 20 | import { test as baseTest, expect } from './fixtures.js'; 21 | 22 | import type { ChildProcess } from 'child_process'; 23 | 24 | const __filename = url.fileURLToPath(import.meta.url); 25 | 26 | const test = baseTest.extend<{ agentEndpoint: (options?: { args?: string[] }) => Promise<{ url: URL, stdout: () => string }> }>({ 27 | agentEndpoint: async ({}, use) => { 28 | let cp: ChildProcess | undefined; 29 | await use(async (options?: { args?: string[] }) => { 30 | if (cp) 31 | throw new Error('Process already running'); 32 | 33 | cp = spawn('node', [ 34 | path.join(path.dirname(__filename), '../lib/browserServer.js'), 35 | ...(options?.args || []), 36 | ], { 37 | stdio: 'pipe', 38 | env: { 39 | ...process.env, 40 | DEBUG: 'pw:mcp:test', 41 | DEBUG_COLORS: '0', 42 | DEBUG_HIDE_DATE: '1', 43 | }, 44 | }); 45 | let stdout = ''; 46 | const url = await new Promise(resolve => cp!.stdout?.on('data', data => { 47 | stdout += data.toString(); 48 | const match = stdout.match(/Listening on (http:\/\/.*)/); 49 | if (match) 50 | resolve(match[1]); 51 | })); 52 | 53 | return { url: new URL(url), stdout: () => stdout }; 54 | }); 55 | cp?.kill('SIGTERM'); 56 | }, 57 | }); 58 | 59 | test.skip(({ mcpBrowser }) => mcpBrowser !== 'chrome', 'Agent is CDP-only for now'); 60 | 61 | test('browser lifecycle', async ({ agentEndpoint, startClient, server }) => { 62 | const { url: agentUrl } = await agentEndpoint(); 63 | const { client: client1 } = await startClient({ args: ['--browser-agent', agentUrl.toString()] }); 64 | expect(await client1.callTool({ 65 | name: 'browser_navigate', 66 | arguments: { url: server.HELLO_WORLD }, 67 | })).toContainTextContent('Hello, world!'); 68 | 69 | const { client: client2 } = await startClient({ args: ['--browser-agent', agentUrl.toString()] }); 70 | expect(await client2.callTool({ 71 | name: 'browser_navigate', 72 | arguments: { url: server.HELLO_WORLD }, 73 | })).toContainTextContent('Hello, world!'); 74 | 75 | await client1.close(); 76 | await client2.close(); 77 | }); 78 | -------------------------------------------------------------------------------- /tests/capabilities.spec.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright (c) Microsoft Corporation. 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | import { test, expect } from './fixtures.js'; 18 | 19 | test('test snapshot tool list', async ({ client }) => { 20 | const { tools } = await client.listTools(); 21 | expect(new Set(tools.map(t => t.name))).toEqual(new Set([ 22 | 'browser_click', 23 | 'browser_console_messages', 24 | 'browser_drag', 25 | 'browser_file_upload', 26 | 'browser_generate_playwright_test', 27 | 'browser_handle_dialog', 28 | 'browser_hover', 29 | 'browser_select_option', 30 | 'browser_type', 31 | 'browser_close', 32 | 'browser_install', 33 | 'browser_navigate_back', 34 | 'browser_navigate_forward', 35 | 'browser_navigate', 36 | 'browser_network_requests', 37 | 'browser_pdf_save', 38 | 'browser_press_key', 39 | 'browser_resize', 40 | 'browser_snapshot', 41 | 'browser_tab_close', 42 | 'browser_tab_list', 43 | 'browser_tab_new', 44 | 'browser_tab_select', 45 | 'browser_take_screenshot', 46 | 'browser_wait_for', 47 | ])); 48 | }); 49 | 50 | test('test vision tool list', async ({ visionClient }) => { 51 | const { tools: visionTools } = await visionClient.listTools(); 52 | expect(new Set(visionTools.map(t => t.name))).toEqual(new Set([ 53 | 'browser_close', 54 | 'browser_console_messages', 55 | 'browser_file_upload', 56 | 'browser_generate_playwright_test', 57 | 'browser_handle_dialog', 58 | 'browser_install', 59 | 'browser_navigate_back', 60 | 'browser_navigate_forward', 61 | 'browser_navigate', 62 | 'browser_network_requests', 63 | 'browser_pdf_save', 64 | 'browser_press_key', 65 | 'browser_resize', 66 | 'browser_screen_capture', 67 | 'browser_screen_click', 68 | 'browser_screen_drag', 69 | 'browser_screen_move_mouse', 70 | 'browser_screen_type', 71 | 'browser_tab_close', 72 | 'browser_tab_list', 73 | 'browser_tab_new', 74 | 'browser_tab_select', 75 | 'browser_wait_for', 76 | ])); 77 | }); 78 | 79 | test('test capabilities', async ({ startClient }) => { 80 | const { client } = await startClient({ 81 | args: ['--caps="core"'], 82 | }); 83 | const { tools } = await client.listTools(); 84 | const toolNames = tools.map(t => t.name); 85 | expect(toolNames).not.toContain('browser_file_upload'); 86 | expect(toolNames).not.toContain('browser_pdf_save'); 87 | expect(toolNames).not.toContain('browser_screen_capture'); 88 | expect(toolNames).not.toContain('browser_screen_click'); 89 | expect(toolNames).not.toContain('browser_screen_drag'); 90 | expect(toolNames).not.toContain('browser_screen_move_mouse'); 91 | expect(toolNames).not.toContain('browser_screen_type'); 92 | }); 93 | -------------------------------------------------------------------------------- /tests/cdp.spec.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright (c) Microsoft Corporation. 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | import { test, expect } from './fixtures.js'; 18 | 19 | test('cdp server', async ({ cdpServer, startClient, server }) => { 20 | await cdpServer.start(); 21 | const { client } = await startClient({ args: [`--cdp-endpoint=${cdpServer.endpoint}`] }); 22 | expect(await client.callTool({ 23 | name: 'browser_navigate', 24 | arguments: { url: server.HELLO_WORLD }, 25 | })).toContainTextContent(`- generic [ref=e1]: Hello, world!`); 26 | }); 27 | 28 | test('cdp server reuse tab', async ({ cdpServer, startClient, server }) => { 29 | const browserContext = await cdpServer.start(); 30 | const { client } = await startClient({ args: [`--cdp-endpoint=${cdpServer.endpoint}`] }); 31 | 32 | const [page] = browserContext.pages(); 33 | await page.goto(server.HELLO_WORLD); 34 | 35 | expect(await client.callTool({ 36 | name: 'browser_click', 37 | arguments: { 38 | element: 'Hello, world!', 39 | ref: 'f0', 40 | }, 41 | })).toHaveTextContent(`Error: No current snapshot available. Capture a snapshot or navigate to a new location first.`); 42 | 43 | expect(await client.callTool({ 44 | name: 'browser_snapshot', 45 | })).toHaveTextContent(` 46 | - Ran Playwright code: 47 | \`\`\`js 48 | // 49 | \`\`\` 50 | 51 | - Page URL: ${server.HELLO_WORLD} 52 | - Page Title: Title 53 | - Page Snapshot 54 | \`\`\`yaml 55 | - generic [ref=e1]: Hello, world! 56 | \`\`\` 57 | `); 58 | }); 59 | 60 | test('should throw connection error and allow re-connecting', async ({ cdpServer, startClient, server }) => { 61 | const { client } = await startClient({ args: [`--cdp-endpoint=${cdpServer.endpoint}`] }); 62 | 63 | server.setContent('/', ` 64 | Title 65 | Hello, world! 66 | `, 'text/html'); 67 | 68 | expect(await client.callTool({ 69 | name: 'browser_navigate', 70 | arguments: { url: server.PREFIX }, 71 | })).toContainTextContent(`Error: browserType.connectOverCDP: connect ECONNREFUSED`); 72 | await cdpServer.start(); 73 | expect(await client.callTool({ 74 | name: 'browser_navigate', 75 | arguments: { url: server.PREFIX }, 76 | })).toContainTextContent(`- generic [ref=e1]: Hello, world!`); 77 | }); 78 | -------------------------------------------------------------------------------- /tests/config.spec.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright (c) Microsoft Corporation. 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | import fs from 'node:fs'; 18 | 19 | import { Config } from '../config.js'; 20 | import { test, expect } from './fixtures.js'; 21 | 22 | test('config user data dir', async ({ startClient, server }, testInfo) => { 23 | server.setContent('/', ` 24 | Title 25 | Hello, world! 26 | `, 'text/html'); 27 | 28 | const config: Config = { 29 | browser: { 30 | userDataDir: testInfo.outputPath('user-data-dir'), 31 | }, 32 | }; 33 | const configPath = testInfo.outputPath('config.json'); 34 | await fs.promises.writeFile(configPath, JSON.stringify(config, null, 2)); 35 | 36 | const { client } = await startClient({ args: ['--config', configPath] }); 37 | expect(await client.callTool({ 38 | name: 'browser_navigate', 39 | arguments: { url: server.PREFIX }, 40 | })).toContainTextContent(`Hello, world!`); 41 | 42 | const files = await fs.promises.readdir(config.browser!.userDataDir!); 43 | expect(files.length).toBeGreaterThan(0); 44 | }); 45 | 46 | test.describe(() => { 47 | test.use({ mcpBrowser: '' }); 48 | test('browserName', { annotation: { type: 'issue', description: 'https://github.com/microsoft/playwright-mcp/issues/458' } }, async ({ startClient }, testInfo) => { 49 | const config: Config = { 50 | browser: { 51 | browserName: 'firefox', 52 | }, 53 | }; 54 | const configPath = testInfo.outputPath('config.json'); 55 | await fs.promises.writeFile(configPath, JSON.stringify(config, null, 2)); 56 | 57 | const { client } = await startClient({ args: ['--config', configPath] }); 58 | expect(await client.callTool({ 59 | name: 'browser_navigate', 60 | arguments: { url: 'data:text/html,' }, 61 | })).toContainTextContent(`Firefox`); 62 | }); 63 | }); 64 | -------------------------------------------------------------------------------- /tests/console.spec.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright (c) Microsoft Corporation. 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | import { test, expect } from './fixtures.js'; 18 | 19 | test('browser_console_messages', async ({ client, server }) => { 20 | server.setContent('/', ` 21 | 22 | 23 | 27 | 28 | `, 'text/html'); 29 | 30 | await client.callTool({ 31 | name: 'browser_navigate', 32 | arguments: { 33 | url: server.PREFIX, 34 | }, 35 | }); 36 | 37 | const resource = await client.callTool({ 38 | name: 'browser_console_messages', 39 | }); 40 | expect(resource).toHaveTextContent([ 41 | '[LOG] Hello, world!', 42 | '[ERROR] Error', 43 | ].join('\n')); 44 | }); 45 | -------------------------------------------------------------------------------- /tests/core.spec.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright (c) Microsoft Corporation. 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | import { test, expect } from './fixtures.js'; 18 | 19 | test('browser_navigate', async ({ client, server }) => { 20 | expect(await client.callTool({ 21 | name: 'browser_navigate', 22 | arguments: { url: server.HELLO_WORLD }, 23 | })).toHaveTextContent(` 24 | - Ran Playwright code: 25 | \`\`\`js 26 | // Navigate to ${server.HELLO_WORLD} 27 | await page.goto('${server.HELLO_WORLD}'); 28 | \`\`\` 29 | 30 | - Page URL: ${server.HELLO_WORLD} 31 | - Page Title: Title 32 | - Page Snapshot 33 | \`\`\`yaml 34 | - generic [ref=e1]: Hello, world! 35 | \`\`\` 36 | ` 37 | ); 38 | }); 39 | 40 | test('browser_click', async ({ client, server }) => { 41 | server.setContent('/', ` 42 | Title 43 | 44 | `, 'text/html'); 45 | 46 | await client.callTool({ 47 | name: 'browser_navigate', 48 | arguments: { url: server.PREFIX }, 49 | }); 50 | 51 | expect(await client.callTool({ 52 | name: 'browser_click', 53 | arguments: { 54 | element: 'Submit button', 55 | ref: 'e2', 56 | }, 57 | })).toHaveTextContent(` 58 | - Ran Playwright code: 59 | \`\`\`js 60 | // Click Submit button 61 | await page.getByRole('button', { name: 'Submit' }).click(); 62 | \`\`\` 63 | 64 | - Page URL: ${server.PREFIX} 65 | - Page Title: Title 66 | - Page Snapshot 67 | \`\`\`yaml 68 | - button "Submit" [ref=e2] 69 | \`\`\` 70 | `); 71 | }); 72 | 73 | test('browser_select_option', async ({ client, server }) => { 74 | server.setContent('/', ` 75 | Title 76 | 80 | `, 'text/html'); 81 | 82 | await client.callTool({ 83 | name: 'browser_navigate', 84 | arguments: { url: server.PREFIX }, 85 | }); 86 | 87 | expect(await client.callTool({ 88 | name: 'browser_select_option', 89 | arguments: { 90 | element: 'Select', 91 | ref: 'e2', 92 | values: ['bar'], 93 | }, 94 | })).toHaveTextContent(` 95 | - Ran Playwright code: 96 | \`\`\`js 97 | // Select options [bar] in Select 98 | await page.getByRole('combobox').selectOption(['bar']); 99 | \`\`\` 100 | 101 | - Page URL: ${server.PREFIX} 102 | - Page Title: Title 103 | - Page Snapshot 104 | \`\`\`yaml 105 | - combobox [ref=e2]: 106 | - option "Foo" 107 | - option "Bar" [selected] 108 | \`\`\` 109 | `); 110 | }); 111 | 112 | test('browser_select_option (multiple)', async ({ client, server }) => { 113 | server.setContent('/', ` 114 | Title 115 | 120 | `, 'text/html'); 121 | 122 | await client.callTool({ 123 | name: 'browser_navigate', 124 | arguments: { url: server.PREFIX }, 125 | }); 126 | 127 | expect(await client.callTool({ 128 | name: 'browser_select_option', 129 | arguments: { 130 | element: 'Select', 131 | ref: 'e2', 132 | values: ['bar', 'baz'], 133 | }, 134 | })).toHaveTextContent(` 135 | - Ran Playwright code: 136 | \`\`\`js 137 | // Select options [bar, baz] in Select 138 | await page.getByRole('listbox').selectOption(['bar', 'baz']); 139 | \`\`\` 140 | 141 | - Page URL: ${server.PREFIX} 142 | - Page Title: Title 143 | - Page Snapshot 144 | \`\`\`yaml 145 | - listbox [ref=e2]: 146 | - option "Foo" [ref=e3] 147 | - option "Bar" [selected] [ref=e4] 148 | - option "Baz" [selected] [ref=e5] 149 | \`\`\` 150 | `); 151 | }); 152 | 153 | test('browser_type', async ({ client, server }) => { 154 | server.setContent('/', ` 155 | 156 | 157 | 158 | 159 | `, 'text/html'); 160 | 161 | await client.callTool({ 162 | name: 'browser_navigate', 163 | arguments: { 164 | url: server.PREFIX, 165 | }, 166 | }); 167 | await client.callTool({ 168 | name: 'browser_type', 169 | arguments: { 170 | element: 'textbox', 171 | ref: 'e2', 172 | text: 'Hi!', 173 | submit: true, 174 | }, 175 | }); 176 | expect(await client.callTool({ 177 | name: 'browser_console_messages', 178 | })).toHaveTextContent('[LOG] Key pressed: Enter , Text: Hi!'); 179 | }); 180 | 181 | test('browser_type (slowly)', async ({ client, server }) => { 182 | server.setContent('/', ` 183 | 184 | `, 'text/html'); 185 | 186 | await client.callTool({ 187 | name: 'browser_navigate', 188 | arguments: { 189 | url: server.PREFIX, 190 | }, 191 | }); 192 | await client.callTool({ 193 | name: 'browser_type', 194 | arguments: { 195 | element: 'textbox', 196 | ref: 'e2', 197 | text: 'Hi!', 198 | submit: true, 199 | slowly: true, 200 | }, 201 | }); 202 | expect(await client.callTool({ 203 | name: 'browser_console_messages', 204 | })).toHaveTextContent([ 205 | '[LOG] Key pressed: H Text: ', 206 | '[LOG] Key pressed: i Text: H', 207 | '[LOG] Key pressed: ! Text: Hi', 208 | '[LOG] Key pressed: Enter Text: Hi!', 209 | ].join('\n')); 210 | }); 211 | 212 | test('browser_resize', async ({ client, server }) => { 213 | server.setContent('/', ` 214 | Resize Test 215 | 216 |
Waiting for resize...
217 | 219 | 220 | `, 'text/html'); 221 | await client.callTool({ 222 | name: 'browser_navigate', 223 | arguments: { url: server.PREFIX }, 224 | }); 225 | 226 | const response = await client.callTool({ 227 | name: 'browser_resize', 228 | arguments: { 229 | width: 390, 230 | height: 780, 231 | }, 232 | }); 233 | expect(response).toContainTextContent(`- Ran Playwright code: 234 | \`\`\`js 235 | // Resize browser window to 390x780 236 | await page.setViewportSize({ width: 390, height: 780 }); 237 | \`\`\``); 238 | await expect.poll(() => client.callTool({ name: 'browser_snapshot' })).toContainTextContent('Window size: 390x780'); 239 | }); 240 | -------------------------------------------------------------------------------- /tests/device.spec.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright (c) Microsoft Corporation. 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | import { test, expect } from './fixtures.js'; 18 | 19 | test('--device should work', async ({ startClient, server }) => { 20 | const { client } = await startClient({ 21 | args: ['--device', 'iPhone 15'], 22 | }); 23 | 24 | server.route('/', (req, res) => { 25 | res.writeHead(200, { 'Content-Type': 'text/html' }); 26 | res.end(` 27 | 28 | 29 | 30 | 31 | 34 | `); 35 | }); 36 | 37 | expect(await client.callTool({ 38 | name: 'browser_navigate', 39 | arguments: { 40 | url: server.PREFIX, 41 | }, 42 | })).toContainTextContent(`393x659`); 43 | }); 44 | -------------------------------------------------------------------------------- /tests/dialogs.spec.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright (c) Microsoft Corporation. 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | import { test, expect } from './fixtures.js'; 18 | 19 | // https://github.com/microsoft/playwright/issues/35663 20 | test.skip(({ mcpBrowser, mcpHeadless }) => mcpBrowser === 'webkit' && mcpHeadless); 21 | 22 | test('alert dialog', async ({ client, server }) => { 23 | server.setContent('/', ``, 'text/html'); 24 | expect(await client.callTool({ 25 | name: 'browser_navigate', 26 | arguments: { url: server.PREFIX }, 27 | })).toContainTextContent('- button "Button" [ref=e2]'); 28 | 29 | expect(await client.callTool({ 30 | name: 'browser_click', 31 | arguments: { 32 | element: 'Button', 33 | ref: 'e2', 34 | }, 35 | })).toHaveTextContent(`- Ran Playwright code: 36 | \`\`\`js 37 | // Click Button 38 | await page.getByRole('button', { name: 'Button' }).click(); 39 | \`\`\` 40 | 41 | ### Modal state 42 | - ["alert" dialog with message "Alert"]: can be handled by the "browser_handle_dialog" tool`); 43 | 44 | const result = await client.callTool({ 45 | name: 'browser_handle_dialog', 46 | arguments: { 47 | accept: true, 48 | }, 49 | }); 50 | 51 | expect(result).not.toContainTextContent('### Modal state'); 52 | expect(result).toHaveTextContent(`- Ran Playwright code: 53 | \`\`\`js 54 | // 55 | \`\`\` 56 | 57 | - Page URL: ${server.PREFIX} 58 | - Page Title: 59 | - Page Snapshot 60 | \`\`\`yaml 61 | - button "Button" [ref=e2] 62 | \`\`\` 63 | `); 64 | }); 65 | 66 | test('two alert dialogs', async ({ client, server }) => { 67 | test.fixme(true, 'Race between the dialog and ariaSnapshot'); 68 | 69 | server.setContent('/', ` 70 | Title 71 | 72 | 73 | 74 | `, 'text/html'); 75 | 76 | expect(await client.callTool({ 77 | name: 'browser_navigate', 78 | arguments: { url: server.PREFIX }, 79 | })).toContainTextContent('- button "Button" [ref=e2]'); 80 | 81 | expect(await client.callTool({ 82 | name: 'browser_click', 83 | arguments: { 84 | element: 'Button', 85 | ref: 'e2', 86 | }, 87 | })).toHaveTextContent(`- Ran Playwright code: 88 | \`\`\`js 89 | // Click Button 90 | await page.getByRole('button', { name: 'Button' }).click(); 91 | \`\`\` 92 | 93 | ### Modal state 94 | - ["alert" dialog with message "Alert 1"]: can be handled by the "browser_handle_dialog" tool`); 95 | 96 | const result = await client.callTool({ 97 | name: 'browser_handle_dialog', 98 | arguments: { 99 | accept: true, 100 | }, 101 | }); 102 | 103 | expect(result).not.toContainTextContent('### Modal state'); 104 | }); 105 | 106 | test('confirm dialog (true)', async ({ client, server }) => { 107 | server.setContent('/', ` 108 | Title 109 | 110 | 111 | 112 | `, 'text/html'); 113 | 114 | expect(await client.callTool({ 115 | name: 'browser_navigate', 116 | arguments: { url: server.PREFIX }, 117 | })).toContainTextContent('- button "Button" [ref=e2]'); 118 | 119 | expect(await client.callTool({ 120 | name: 'browser_click', 121 | arguments: { 122 | element: 'Button', 123 | ref: 'e2', 124 | }, 125 | })).toContainTextContent(`### Modal state 126 | - ["confirm" dialog with message "Confirm"]: can be handled by the "browser_handle_dialog" tool`); 127 | 128 | const result = await client.callTool({ 129 | name: 'browser_handle_dialog', 130 | arguments: { 131 | accept: true, 132 | }, 133 | }); 134 | 135 | expect(result).not.toContainTextContent('### Modal state'); 136 | expect(result).toContainTextContent('// '); 137 | expect(result).toContainTextContent(`- Page Snapshot 138 | \`\`\`yaml 139 | - generic [ref=e1]: "true" 140 | \`\`\``); 141 | }); 142 | 143 | test('confirm dialog (false)', async ({ client, server }) => { 144 | server.setContent('/', ` 145 | Title 146 | 147 | 148 | 149 | `, 'text/html'); 150 | 151 | expect(await client.callTool({ 152 | name: 'browser_navigate', 153 | arguments: { url: server.PREFIX }, 154 | })).toContainTextContent('- button "Button" [ref=e2]'); 155 | 156 | expect(await client.callTool({ 157 | name: 'browser_click', 158 | arguments: { 159 | element: 'Button', 160 | ref: 'e2', 161 | }, 162 | })).toContainTextContent(`### Modal state 163 | - ["confirm" dialog with message "Confirm"]: can be handled by the "browser_handle_dialog" tool`); 164 | 165 | const result = await client.callTool({ 166 | name: 'browser_handle_dialog', 167 | arguments: { 168 | accept: false, 169 | }, 170 | }); 171 | 172 | expect(result).toContainTextContent(`- Page Snapshot 173 | \`\`\`yaml 174 | - generic [ref=e1]: "false" 175 | \`\`\``); 176 | }); 177 | 178 | test('prompt dialog', async ({ client, server }) => { 179 | server.setContent('/', ` 180 | Title 181 | 182 | 183 | 184 | `, 'text/html'); 185 | 186 | expect(await client.callTool({ 187 | name: 'browser_navigate', 188 | arguments: { url: server.PREFIX }, 189 | })).toContainTextContent('- button "Button" [ref=e2]'); 190 | 191 | expect(await client.callTool({ 192 | name: 'browser_click', 193 | arguments: { 194 | element: 'Button', 195 | ref: 'e2', 196 | }, 197 | })).toContainTextContent(`### Modal state 198 | - ["prompt" dialog with message "Prompt"]: can be handled by the "browser_handle_dialog" tool`); 199 | 200 | const result = await client.callTool({ 201 | name: 'browser_handle_dialog', 202 | arguments: { 203 | accept: true, 204 | promptText: 'Answer', 205 | }, 206 | }); 207 | 208 | expect(result).toContainTextContent(`- Page Snapshot 209 | \`\`\`yaml 210 | - generic [ref=e1]: Answer 211 | \`\`\``); 212 | }); 213 | -------------------------------------------------------------------------------- /tests/files.spec.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright (c) Microsoft Corporation. 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | import { test, expect } from './fixtures.js'; 18 | import fs from 'fs/promises'; 19 | 20 | test('browser_file_upload', async ({ client, server }, testInfo) => { 21 | server.setContent('/', ` 22 | 23 | 24 | `, 'text/html'); 25 | 26 | expect(await client.callTool({ 27 | name: 'browser_navigate', 28 | arguments: { url: server.PREFIX }, 29 | })).toContainTextContent(` 30 | \`\`\`yaml 31 | - generic [ref=e1]: 32 | - button "Choose File" [ref=e2] 33 | - button "Button" [ref=e3] 34 | \`\`\``); 35 | 36 | { 37 | expect(await client.callTool({ 38 | name: 'browser_file_upload', 39 | arguments: { paths: [] }, 40 | })).toHaveTextContent(` 41 | The tool "browser_file_upload" can only be used when there is related modal state present. 42 | ### Modal state 43 | - There is no modal state present 44 | `.trim()); 45 | } 46 | 47 | expect(await client.callTool({ 48 | name: 'browser_click', 49 | arguments: { 50 | element: 'Textbox', 51 | ref: 'e2', 52 | }, 53 | })).toContainTextContent(`### Modal state 54 | - [File chooser]: can be handled by the "browser_file_upload" tool`); 55 | 56 | const filePath = testInfo.outputPath('test.txt'); 57 | await fs.writeFile(filePath, 'Hello, world!'); 58 | 59 | { 60 | const response = await client.callTool({ 61 | name: 'browser_file_upload', 62 | arguments: { 63 | paths: [filePath], 64 | }, 65 | }); 66 | 67 | expect(response).not.toContainTextContent('### Modal state'); 68 | expect(response).toContainTextContent(` 69 | \`\`\`yaml 70 | - generic [ref=e1]: 71 | - button "Choose File" [ref=e2] 72 | - button "Button" [ref=e3] 73 | \`\`\``); 74 | } 75 | 76 | { 77 | const response = await client.callTool({ 78 | name: 'browser_click', 79 | arguments: { 80 | element: 'Textbox', 81 | ref: 'e2', 82 | }, 83 | }); 84 | 85 | expect(response).toContainTextContent('- [File chooser]: can be handled by the \"browser_file_upload\" tool'); 86 | } 87 | 88 | { 89 | const response = await client.callTool({ 90 | name: 'browser_click', 91 | arguments: { 92 | element: 'Button', 93 | ref: 'e3', 94 | }, 95 | }); 96 | 97 | expect(response).toContainTextContent(`Tool "browser_click" does not handle the modal state. 98 | ### Modal state 99 | - [File chooser]: can be handled by the "browser_file_upload" tool`); 100 | } 101 | }); 102 | 103 | test('clicking on download link emits download', async ({ startClient, server }, testInfo) => { 104 | const { client } = await startClient({ 105 | config: { outputDir: testInfo.outputPath('output') }, 106 | }); 107 | 108 | server.setContent('/', `Download`, 'text/html'); 109 | server.setContent('/download', 'Data', 'text/plain'); 110 | 111 | expect(await client.callTool({ 112 | name: 'browser_navigate', 113 | arguments: { url: server.PREFIX }, 114 | })).toContainTextContent('- link "Download" [ref=e2]'); 115 | await client.callTool({ 116 | name: 'browser_click', 117 | arguments: { 118 | element: 'Download link', 119 | ref: 'e2', 120 | }, 121 | }); 122 | await expect.poll(() => client.callTool({ name: 'browser_snapshot' })).toContainTextContent(` 123 | ### Downloads 124 | - Downloaded file test.txt to ${testInfo.outputPath('output', 'test.txt')}`); 125 | }); 126 | 127 | test('navigating to download link emits download', async ({ startClient, server, mcpBrowser }, testInfo) => { 128 | const { client } = await startClient({ 129 | config: { outputDir: testInfo.outputPath('output') }, 130 | }); 131 | 132 | test.skip(mcpBrowser === 'webkit' && process.platform === 'linux', 'https://github.com/microsoft/playwright/blob/8e08fdb52c27bb75de9bf87627bf740fadab2122/tests/library/download.spec.ts#L436'); 133 | server.route('/download', (req, res) => { 134 | res.writeHead(200, { 135 | 'Content-Type': 'text/plain', 136 | 'Content-Disposition': 'attachment; filename=test.txt', 137 | }); 138 | res.end('Hello world!'); 139 | }); 140 | 141 | expect(await client.callTool({ 142 | name: 'browser_navigate', 143 | arguments: { 144 | url: server.PREFIX + 'download', 145 | }, 146 | })).toContainTextContent('### Downloads'); 147 | }); 148 | -------------------------------------------------------------------------------- /tests/headed.spec.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright (c) Microsoft Corporation. 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | import { test, expect } from './fixtures.js'; 18 | 19 | for (const mcpHeadless of [false, true]) { 20 | test.describe(`mcpHeadless: ${mcpHeadless}`, () => { 21 | test.use({ mcpHeadless }); 22 | test.skip(process.platform === 'linux', 'Auto-detection wont let this test run on linux'); 23 | test.skip(({ mcpMode, mcpHeadless }) => mcpMode === 'docker' && !mcpHeadless, 'Headed mode is not supported in docker'); 24 | test('browser', async ({ client, server, mcpBrowser }) => { 25 | test.skip(!['chrome', 'msedge', 'chromium'].includes(mcpBrowser ?? ''), 'Only chrome is supported for this test'); 26 | server.route('/', (req, res) => { 27 | res.writeHead(200, { 'Content-Type': 'text/html' }); 28 | res.end(` 29 | 30 | 33 | `); 34 | }); 35 | 36 | const response = await client.callTool({ 37 | name: 'browser_navigate', 38 | arguments: { 39 | url: server.PREFIX, 40 | }, 41 | }); 42 | 43 | expect(response).toContainTextContent(`Mozilla/5.0`); 44 | if (mcpHeadless) 45 | expect(response).toContainTextContent(`HeadlessChrome`); 46 | else 47 | expect(response).not.toContainTextContent(`HeadlessChrome`); 48 | }); 49 | }); 50 | } 51 | -------------------------------------------------------------------------------- /tests/iframes.spec.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright (c) Microsoft Corporation. 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | import { test, expect } from './fixtures.js'; 18 | 19 | test('stitched aria frames', async ({ client }) => { 20 | expect(await client.callTool({ 21 | name: 'browser_navigate', 22 | arguments: { 23 | url: `data:text/html,

Hello

`, 24 | }, 25 | })).toContainTextContent(` 26 | \`\`\`yaml 27 | - generic [ref=e1]: 28 | - heading "Hello" [level=1] [ref=e2] 29 | - iframe [ref=e3]: 30 | - generic [ref=f1e1]: 31 | - button "World" [ref=f1e2] 32 | - main [ref=f1e3]: 33 | - iframe [ref=f1e4]: 34 | - paragraph [ref=f2e2]: Nested 35 | \`\`\``); 36 | 37 | expect(await client.callTool({ 38 | name: 'browser_click', 39 | arguments: { 40 | element: 'World', 41 | ref: 'f1e2', 42 | }, 43 | })).toContainTextContent(`// Click World`); 44 | }); 45 | -------------------------------------------------------------------------------- /tests/install.spec.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright (c) Microsoft Corporation. 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | import { test, expect } from './fixtures.js'; 18 | 19 | test('browser_install', async ({ client, mcpBrowser }) => { 20 | test.skip(mcpBrowser !== 'chromium', 'Test only chromium'); 21 | expect(await client.callTool({ 22 | name: 'browser_install', 23 | })).toContainTextContent(`No open pages available.`); 24 | }); 25 | -------------------------------------------------------------------------------- /tests/launch.spec.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright (c) Microsoft Corporation. 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | import fs from 'fs'; 18 | 19 | import { test, expect, formatOutput } from './fixtures.js'; 20 | 21 | test('test reopen browser', async ({ startClient, server }) => { 22 | const { client, stderr } = await startClient(); 23 | await client.callTool({ 24 | name: 'browser_navigate', 25 | arguments: { url: server.HELLO_WORLD }, 26 | }); 27 | 28 | expect(await client.callTool({ 29 | name: 'browser_close', 30 | })).toContainTextContent('No open pages available'); 31 | 32 | expect(await client.callTool({ 33 | name: 'browser_navigate', 34 | arguments: { url: server.HELLO_WORLD }, 35 | })).toContainTextContent(`- generic [ref=e1]: Hello, world!`); 36 | 37 | await client.close(); 38 | 39 | if (process.platform === 'win32') 40 | return; 41 | 42 | await expect.poll(() => formatOutput(stderr()), { timeout: 0 }).toEqual([ 43 | 'create context', 44 | 'create browser context (persistent)', 45 | 'lock user data dir', 46 | 'close context', 47 | 'close browser context (persistent)', 48 | 'release user data dir', 49 | 'close browser context complete (persistent)', 50 | 'create browser context (persistent)', 51 | 'lock user data dir', 52 | 'close context', 53 | 'close browser context (persistent)', 54 | 'release user data dir', 55 | 'close browser context complete (persistent)', 56 | ]); 57 | }); 58 | 59 | test('executable path', async ({ startClient, server }) => { 60 | const { client } = await startClient({ args: [`--executable-path=bogus`] }); 61 | const response = await client.callTool({ 62 | name: 'browser_navigate', 63 | arguments: { url: server.HELLO_WORLD }, 64 | }); 65 | expect(response).toContainTextContent(`executable doesn't exist`); 66 | }); 67 | 68 | test('persistent context', async ({ startClient, server }) => { 69 | server.setContent('/', ` 70 | 71 | 72 | 76 | `, 'text/html'); 77 | 78 | const { client } = await startClient(); 79 | const response = await client.callTool({ 80 | name: 'browser_navigate', 81 | arguments: { url: server.PREFIX }, 82 | }); 83 | expect(response).toContainTextContent(`Storage: NO`); 84 | 85 | await new Promise(resolve => setTimeout(resolve, 3000)); 86 | 87 | await client.callTool({ 88 | name: 'browser_close', 89 | }); 90 | 91 | const { client: client2 } = await startClient(); 92 | const response2 = await client2.callTool({ 93 | name: 'browser_navigate', 94 | arguments: { url: server.PREFIX }, 95 | }); 96 | 97 | expect(response2).toContainTextContent(`Storage: YES`); 98 | }); 99 | 100 | test('isolated context', async ({ startClient, server }) => { 101 | server.setContent('/', ` 102 | 103 | 104 | 108 | `, 'text/html'); 109 | 110 | const { client: client1 } = await startClient({ args: [`--isolated`] }); 111 | const response = await client1.callTool({ 112 | name: 'browser_navigate', 113 | arguments: { url: server.PREFIX }, 114 | }); 115 | expect(response).toContainTextContent(`Storage: NO`); 116 | 117 | await client1.callTool({ 118 | name: 'browser_close', 119 | }); 120 | 121 | const { client: client2 } = await startClient({ args: [`--isolated`] }); 122 | const response2 = await client2.callTool({ 123 | name: 'browser_navigate', 124 | arguments: { url: server.PREFIX }, 125 | }); 126 | expect(response2).toContainTextContent(`Storage: NO`); 127 | }); 128 | 129 | test('isolated context with storage state', async ({ startClient, server }, testInfo) => { 130 | const storageStatePath = testInfo.outputPath('storage-state.json'); 131 | await fs.promises.writeFile(storageStatePath, JSON.stringify({ 132 | origins: [ 133 | { 134 | origin: server.PREFIX, 135 | localStorage: [{ name: 'test', value: 'session-value' }], 136 | }, 137 | ], 138 | })); 139 | 140 | server.setContent('/', ` 141 | 142 | 143 | 146 | `, 'text/html'); 147 | 148 | const { client } = await startClient({ args: [ 149 | `--isolated`, 150 | `--storage-state=${storageStatePath}`, 151 | ] }); 152 | const response = await client.callTool({ 153 | name: 'browser_navigate', 154 | arguments: { url: server.PREFIX }, 155 | }); 156 | expect(response).toContainTextContent(`Storage: session-value`); 157 | }); 158 | -------------------------------------------------------------------------------- /tests/library.spec.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright (c) Microsoft Corporation. 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | import { test, expect } from './fixtures.js'; 17 | import fs from 'node:fs/promises'; 18 | import child_process from 'node:child_process'; 19 | 20 | test('library can be used from CommonJS', { annotation: { type: 'issue', description: 'https://github.com/microsoft/playwright-mcp/issues/456' } }, async ({}, testInfo) => { 21 | const file = testInfo.outputPath('main.cjs'); 22 | await fs.writeFile(file, ` 23 | import('@playwright/mcp') 24 | .then(playwrightMCP => playwrightMCP.createConnection()) 25 | .then(() => console.log('OK')); 26 | `); 27 | expect(child_process.execSync(`node ${file}`, { encoding: 'utf-8' })).toContain('OK'); 28 | }); 29 | -------------------------------------------------------------------------------- /tests/network.spec.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright (c) Microsoft Corporation. 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | import { test, expect } from './fixtures.js'; 18 | 19 | test('browser_network_requests', async ({ client, server }) => { 20 | server.setContent('/', ` 21 | 22 | `, 'text/html'); 23 | 24 | server.setContent('/json', JSON.stringify({ name: 'John Doe' }), 'application/json'); 25 | 26 | await client.callTool({ 27 | name: 'browser_navigate', 28 | arguments: { 29 | url: server.PREFIX, 30 | }, 31 | }); 32 | 33 | await client.callTool({ 34 | name: 'browser_click', 35 | arguments: { 36 | element: 'Click me button', 37 | ref: 'e2', 38 | }, 39 | }); 40 | 41 | await expect.poll(() => client.callTool({ 42 | name: 'browser_network_requests', 43 | })).toHaveTextContent(`[GET] ${`${server.PREFIX}`} => [200] OK 44 | [GET] ${`${server.PREFIX}json`} => [200] OK`); 45 | }); 46 | -------------------------------------------------------------------------------- /tests/pdf.spec.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright (c) Microsoft Corporation. 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | import fs from 'fs'; 18 | 19 | import { test, expect } from './fixtures.js'; 20 | 21 | test('save as pdf unavailable', async ({ startClient, server }) => { 22 | const { client } = await startClient({ args: ['--caps="no-pdf"'] }); 23 | await client.callTool({ 24 | name: 'browser_navigate', 25 | arguments: { url: server.HELLO_WORLD }, 26 | }); 27 | 28 | expect(await client.callTool({ 29 | name: 'browser_pdf_save', 30 | })).toHaveTextContent(/Tool \"browser_pdf_save\" not found/); 31 | }); 32 | 33 | test('save as pdf', async ({ startClient, mcpBrowser, server }, testInfo) => { 34 | const { client } = await startClient({ 35 | config: { outputDir: testInfo.outputPath('output') }, 36 | }); 37 | 38 | test.skip(!!mcpBrowser && !['chromium', 'chrome', 'msedge'].includes(mcpBrowser), 'Save as PDF is only supported in Chromium.'); 39 | 40 | expect(await client.callTool({ 41 | name: 'browser_navigate', 42 | arguments: { url: server.HELLO_WORLD }, 43 | })).toContainTextContent(`- generic [ref=e1]: Hello, world!`); 44 | 45 | const response = await client.callTool({ 46 | name: 'browser_pdf_save', 47 | }); 48 | expect(response).toHaveTextContent(/Save page as.*page-[^:]+.pdf/); 49 | }); 50 | 51 | test('save as pdf (filename: output.pdf)', async ({ startClient, mcpBrowser, server }, testInfo) => { 52 | const outputDir = testInfo.outputPath('output'); 53 | test.skip(!!mcpBrowser && !['chromium', 'chrome', 'msedge'].includes(mcpBrowser), 'Save as PDF is only supported in Chromium.'); 54 | const { client } = await startClient({ 55 | config: { outputDir }, 56 | }); 57 | 58 | expect(await client.callTool({ 59 | name: 'browser_navigate', 60 | arguments: { url: server.HELLO_WORLD }, 61 | })).toContainTextContent(`- generic [ref=e1]: Hello, world!`); 62 | 63 | expect(await client.callTool({ 64 | name: 'browser_pdf_save', 65 | arguments: { 66 | filename: 'output.pdf', 67 | }, 68 | })).toEqual({ 69 | content: [ 70 | { 71 | type: 'text', 72 | text: expect.stringContaining(`output.pdf`), 73 | }, 74 | ], 75 | }); 76 | 77 | const files = [...fs.readdirSync(outputDir)]; 78 | 79 | expect(fs.existsSync(outputDir)).toBeTruthy(); 80 | const pdfFiles = files.filter(f => f.endsWith('.pdf')); 81 | expect(pdfFiles).toHaveLength(1); 82 | expect(pdfFiles[0]).toMatch(/^output.pdf$/); 83 | }); 84 | -------------------------------------------------------------------------------- /tests/request-blocking.spec.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright (c) Microsoft Corporation. 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | import { Client } from '@modelcontextprotocol/sdk/client/index.js'; 18 | import { test, expect } from './fixtures.ts'; 19 | 20 | const BLOCK_MESSAGE = /Blocked by Web Inspector|NS_ERROR_FAILURE|net::ERR_BLOCKED_BY_CLIENT/g; 21 | 22 | const fetchPage = async (client: Client, url: string) => { 23 | const result = await client.callTool({ 24 | name: 'browser_navigate', 25 | arguments: { 26 | url, 27 | }, 28 | }); 29 | 30 | return JSON.stringify(result, null, 2); 31 | }; 32 | 33 | test('default to allow all', async ({ server, client }) => { 34 | server.setContent('/ppp', 'content:PPP', 'text/html'); 35 | const result = await fetchPage(client, server.PREFIX + 'ppp'); 36 | expect(result).toContain('content:PPP'); 37 | }); 38 | 39 | test('blocked works', async ({ startClient }) => { 40 | const { client } = await startClient({ 41 | args: ['--blocked-origins', 'microsoft.com;example.com;playwright.dev'] 42 | }); 43 | const result = await fetchPage(client, 'https://example.com/'); 44 | expect(result).toMatch(BLOCK_MESSAGE); 45 | }); 46 | 47 | test('allowed works', async ({ server, startClient }) => { 48 | server.setContent('/ppp', 'content:PPP', 'text/html'); 49 | const { client } = await startClient({ 50 | args: ['--allowed-origins', `microsoft.com;${new URL(server.PREFIX).host};playwright.dev`] 51 | }); 52 | const result = await fetchPage(client, server.PREFIX + 'ppp'); 53 | expect(result).toContain('content:PPP'); 54 | }); 55 | 56 | test('blocked takes precedence', async ({ startClient }) => { 57 | const { client } = await startClient({ 58 | args: [ 59 | '--blocked-origins', 'example.com', 60 | '--allowed-origins', 'example.com', 61 | ], 62 | }); 63 | const result = await fetchPage(client, 'https://example.com/'); 64 | expect(result).toMatch(BLOCK_MESSAGE); 65 | }); 66 | 67 | test('allowed without blocked blocks all non-explicitly specified origins', async ({ startClient }) => { 68 | const { client } = await startClient({ 69 | args: ['--allowed-origins', 'playwright.dev'], 70 | }); 71 | const result = await fetchPage(client, 'https://example.com/'); 72 | expect(result).toMatch(BLOCK_MESSAGE); 73 | }); 74 | 75 | test('blocked without allowed allows non-explicitly specified origins', async ({ server, startClient }) => { 76 | server.setContent('/ppp', 'content:PPP', 'text/html'); 77 | const { client } = await startClient({ 78 | args: ['--blocked-origins', 'example.com'], 79 | }); 80 | const result = await fetchPage(client, server.PREFIX + 'ppp'); 81 | expect(result).toContain('content:PPP'); 82 | }); 83 | -------------------------------------------------------------------------------- /tests/screenshot.spec.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright (c) Microsoft Corporation. 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | import fs from 'fs'; 18 | 19 | import { test, expect } from './fixtures.js'; 20 | 21 | test('browser_take_screenshot (viewport)', async ({ startClient, server }, testInfo) => { 22 | const { client } = await startClient({ 23 | config: { outputDir: testInfo.outputPath('output') }, 24 | }); 25 | expect(await client.callTool({ 26 | name: 'browser_navigate', 27 | arguments: { url: server.HELLO_WORLD }, 28 | })).toContainTextContent(`Navigate to http://localhost`); 29 | 30 | expect(await client.callTool({ 31 | name: 'browser_take_screenshot', 32 | })).toEqual({ 33 | content: [ 34 | { 35 | data: expect.any(String), 36 | mimeType: 'image/jpeg', 37 | type: 'image', 38 | }, 39 | { 40 | text: expect.stringContaining(`Screenshot viewport and save it as`), 41 | type: 'text', 42 | }, 43 | ], 44 | }); 45 | }); 46 | 47 | test('browser_take_screenshot (element)', async ({ startClient, server }, testInfo) => { 48 | const { client } = await startClient({ 49 | config: { outputDir: testInfo.outputPath('output') }, 50 | }); 51 | expect(await client.callTool({ 52 | name: 'browser_navigate', 53 | arguments: { url: server.HELLO_WORLD }, 54 | })).toContainTextContent(`[ref=e1]`); 55 | 56 | expect(await client.callTool({ 57 | name: 'browser_take_screenshot', 58 | arguments: { 59 | element: 'hello button', 60 | ref: 'e1', 61 | }, 62 | })).toEqual({ 63 | content: [ 64 | { 65 | data: expect.any(String), 66 | mimeType: 'image/jpeg', 67 | type: 'image', 68 | }, 69 | { 70 | text: expect.stringContaining(`page.getByText('Hello, world!').screenshot`), 71 | type: 'text', 72 | }, 73 | ], 74 | }); 75 | }); 76 | 77 | test('--output-dir should work', async ({ startClient, server }, testInfo) => { 78 | const outputDir = testInfo.outputPath('output'); 79 | const { client } = await startClient({ 80 | config: { outputDir }, 81 | }); 82 | expect(await client.callTool({ 83 | name: 'browser_navigate', 84 | arguments: { url: server.HELLO_WORLD }, 85 | })).toContainTextContent(`Navigate to http://localhost`); 86 | 87 | await client.callTool({ 88 | name: 'browser_take_screenshot', 89 | }); 90 | 91 | expect(fs.existsSync(outputDir)).toBeTruthy(); 92 | const files = [...fs.readdirSync(outputDir)].filter(f => f.endsWith('.jpeg')); 93 | expect(files).toHaveLength(1); 94 | expect(files[0]).toMatch(/^page-\d{4}-\d{2}-\d{2}T\d{2}-\d{2}-\d{2}-\d{3}Z\.jpeg$/); 95 | }); 96 | 97 | for (const raw of [undefined, true]) { 98 | test(`browser_take_screenshot (raw: ${raw})`, async ({ startClient, server }, testInfo) => { 99 | const outputDir = testInfo.outputPath('output'); 100 | const ext = raw ? 'png' : 'jpeg'; 101 | const { client } = await startClient({ 102 | config: { outputDir }, 103 | }); 104 | expect(await client.callTool({ 105 | name: 'browser_navigate', 106 | arguments: { url: server.PREFIX }, 107 | })).toContainTextContent(`Navigate to http://localhost`); 108 | 109 | expect(await client.callTool({ 110 | name: 'browser_take_screenshot', 111 | arguments: { raw }, 112 | })).toEqual({ 113 | content: [ 114 | { 115 | data: expect.any(String), 116 | mimeType: `image/${ext}`, 117 | type: 'image', 118 | }, 119 | { 120 | text: expect.stringMatching( 121 | new RegExp(`page-\\d{4}-\\d{2}-\\d{2}T\\d{2}-\\d{2}-\\d{2}\\-\\d{3}Z\\.${ext}`) 122 | ), 123 | type: 'text', 124 | }, 125 | ], 126 | }); 127 | 128 | const files = [...fs.readdirSync(outputDir)].filter(f => f.endsWith(`.${ext}`)); 129 | 130 | expect(fs.existsSync(outputDir)).toBeTruthy(); 131 | expect(files).toHaveLength(1); 132 | expect(files[0]).toMatch( 133 | new RegExp(`^page-\\d{4}-\\d{2}-\\d{2}T\\d{2}-\\d{2}-\\d{2}-\\d{3}Z\\.${ext}$`) 134 | ); 135 | }); 136 | 137 | } 138 | 139 | test('browser_take_screenshot (filename: "output.jpeg")', async ({ startClient, server }, testInfo) => { 140 | const outputDir = testInfo.outputPath('output'); 141 | const { client } = await startClient({ 142 | config: { outputDir }, 143 | }); 144 | expect(await client.callTool({ 145 | name: 'browser_navigate', 146 | arguments: { url: server.HELLO_WORLD }, 147 | })).toContainTextContent(`Navigate to http://localhost`); 148 | 149 | expect(await client.callTool({ 150 | name: 'browser_take_screenshot', 151 | arguments: { 152 | filename: 'output.jpeg', 153 | }, 154 | })).toEqual({ 155 | content: [ 156 | { 157 | data: expect.any(String), 158 | mimeType: 'image/jpeg', 159 | type: 'image', 160 | }, 161 | { 162 | text: expect.stringContaining(`output.jpeg`), 163 | type: 'text', 164 | }, 165 | ], 166 | }); 167 | 168 | const files = [...fs.readdirSync(outputDir)].filter(f => f.endsWith('.jpeg')); 169 | 170 | expect(fs.existsSync(outputDir)).toBeTruthy(); 171 | expect(files).toHaveLength(1); 172 | expect(files[0]).toMatch(/^output\.jpeg$/); 173 | }); 174 | 175 | test('browser_take_screenshot (imageResponses=omit)', async ({ startClient, server }, testInfo) => { 176 | const outputDir = testInfo.outputPath('output'); 177 | const { client } = await startClient({ 178 | config: { 179 | outputDir, 180 | imageResponses: 'omit', 181 | }, 182 | }); 183 | 184 | expect(await client.callTool({ 185 | name: 'browser_navigate', 186 | arguments: { url: server.HELLO_WORLD }, 187 | })).toContainTextContent(`Navigate to http://localhost`); 188 | 189 | await client.callTool({ 190 | name: 'browser_take_screenshot', 191 | }); 192 | 193 | expect(await client.callTool({ 194 | name: 'browser_take_screenshot', 195 | })).toEqual({ 196 | content: [ 197 | { 198 | text: expect.stringContaining(`Screenshot viewport and save it as`), 199 | type: 'text', 200 | }, 201 | ], 202 | }); 203 | }); 204 | 205 | test('browser_take_screenshot (cursor)', async ({ startClient, server }, testInfo) => { 206 | const outputDir = testInfo.outputPath('output'); 207 | 208 | const { client } = await startClient({ 209 | clientName: 'cursor:vscode', 210 | config: { outputDir }, 211 | }); 212 | 213 | expect(await client.callTool({ 214 | name: 'browser_navigate', 215 | arguments: { url: server.HELLO_WORLD }, 216 | })).toContainTextContent(`Navigate to http://localhost`); 217 | 218 | await client.callTool({ 219 | name: 'browser_take_screenshot', 220 | }); 221 | 222 | expect(await client.callTool({ 223 | name: 'browser_take_screenshot', 224 | })).toEqual({ 225 | content: [ 226 | { 227 | text: expect.stringContaining(`Screenshot viewport and save it as`), 228 | type: 'text', 229 | }, 230 | ], 231 | }); 232 | }); 233 | -------------------------------------------------------------------------------- /tests/tabs.spec.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright (c) Microsoft Corporation. 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | import { test, expect } from './fixtures.js'; 18 | 19 | import type { Client } from '@modelcontextprotocol/sdk/client/index.js'; 20 | 21 | async function createTab(client: Client, title: string, body: string) { 22 | return await client.callTool({ 23 | name: 'browser_tab_new', 24 | arguments: { 25 | url: `data:text/html,${title}${body}`, 26 | }, 27 | }); 28 | } 29 | 30 | test('list initial tabs', async ({ client }) => { 31 | expect(await client.callTool({ 32 | name: 'browser_tab_list', 33 | })).toHaveTextContent(`### Open tabs 34 | - 1: (current) [] (about:blank)`); 35 | }); 36 | 37 | test('list first tab', async ({ client }) => { 38 | await createTab(client, 'Tab one', 'Body one'); 39 | expect(await client.callTool({ 40 | name: 'browser_tab_list', 41 | })).toHaveTextContent(`### Open tabs 42 | - 1: [] (about:blank) 43 | - 2: (current) [Tab one] (data:text/html,Tab oneBody one)`); 44 | }); 45 | 46 | test('create new tab', async ({ client }) => { 47 | expect(await createTab(client, 'Tab one', 'Body one')).toHaveTextContent(` 48 | - Ran Playwright code: 49 | \`\`\`js 50 | // 51 | \`\`\` 52 | 53 | ### Open tabs 54 | - 1: [] (about:blank) 55 | - 2: (current) [Tab one] (data:text/html,Tab oneBody one) 56 | 57 | ### Current tab 58 | - Page URL: data:text/html,Tab oneBody one 59 | - Page Title: Tab one 60 | - Page Snapshot 61 | \`\`\`yaml 62 | - generic [ref=e1]: Body one 63 | \`\`\``); 64 | 65 | expect(await createTab(client, 'Tab two', 'Body two')).toHaveTextContent(` 66 | - Ran Playwright code: 67 | \`\`\`js 68 | // 69 | \`\`\` 70 | 71 | ### Open tabs 72 | - 1: [] (about:blank) 73 | - 2: [Tab one] (data:text/html,Tab oneBody one) 74 | - 3: (current) [Tab two] (data:text/html,Tab twoBody two) 75 | 76 | ### Current tab 77 | - Page URL: data:text/html,Tab twoBody two 78 | - Page Title: Tab two 79 | - Page Snapshot 80 | \`\`\`yaml 81 | - generic [ref=e1]: Body two 82 | \`\`\``); 83 | }); 84 | 85 | test('select tab', async ({ client }) => { 86 | await createTab(client, 'Tab one', 'Body one'); 87 | await createTab(client, 'Tab two', 'Body two'); 88 | expect(await client.callTool({ 89 | name: 'browser_tab_select', 90 | arguments: { 91 | index: 2, 92 | }, 93 | })).toHaveTextContent(` 94 | - Ran Playwright code: 95 | \`\`\`js 96 | // 97 | \`\`\` 98 | 99 | ### Open tabs 100 | - 1: [] (about:blank) 101 | - 2: (current) [Tab one] (data:text/html,Tab oneBody one) 102 | - 3: [Tab two] (data:text/html,Tab twoBody two) 103 | 104 | ### Current tab 105 | - Page URL: data:text/html,Tab oneBody one 106 | - Page Title: Tab one 107 | - Page Snapshot 108 | \`\`\`yaml 109 | - generic [ref=e1]: Body one 110 | \`\`\``); 111 | }); 112 | 113 | test('close tab', async ({ client }) => { 114 | await createTab(client, 'Tab one', 'Body one'); 115 | await createTab(client, 'Tab two', 'Body two'); 116 | expect(await client.callTool({ 117 | name: 'browser_tab_close', 118 | arguments: { 119 | index: 3, 120 | }, 121 | })).toHaveTextContent(` 122 | - Ran Playwright code: 123 | \`\`\`js 124 | // 125 | \`\`\` 126 | 127 | ### Open tabs 128 | - 1: [] (about:blank) 129 | - 2: (current) [Tab one] (data:text/html,Tab oneBody one) 130 | 131 | ### Current tab 132 | - Page URL: data:text/html,Tab oneBody one 133 | - Page Title: Tab one 134 | - Page Snapshot 135 | \`\`\`yaml 136 | - generic [ref=e1]: Body one 137 | \`\`\``); 138 | }); 139 | 140 | test('reuse first tab when navigating', async ({ startClient, cdpServer, server }) => { 141 | const browserContext = await cdpServer.start(); 142 | const pages = browserContext.pages(); 143 | 144 | const { client } = await startClient({ args: [`--cdp-endpoint=${cdpServer.endpoint}`] }); 145 | await client.callTool({ 146 | name: 'browser_navigate', 147 | arguments: { url: server.HELLO_WORLD }, 148 | }); 149 | 150 | expect(pages.length).toBe(1); 151 | expect(await pages[0].title()).toBe('Title'); 152 | }); 153 | -------------------------------------------------------------------------------- /tests/testserver/cert.pem: -------------------------------------------------------------------------------- 1 | -----BEGIN CERTIFICATE----- 2 | MIIFCjCCAvKgAwIBAgIULU/gkDm8IqC7PG8u3RID0AYyP6gwDQYJKoZIhvcNAQEL 3 | BQAwGjEYMBYGA1UEAwwPcGxheXdyaWdodC10ZXN0MB4XDTIzMDgxMDIyNTc1MFoX 4 | DTMzMDgwNzIyNTc1MFowGjEYMBYGA1UEAwwPcGxheXdyaWdodC10ZXN0MIICIjAN 5 | BgkqhkiG9w0BAQEFAAOCAg8AMIICCgKCAgEArbS99qjKcnHr5G0Zc2xhDaOZnjQv 6 | Fbiqxf/nbXt/7WaqryzpVKu7AT1ainBvuPEo7If9DhVnfF//2pGl0gbU31OU4/mr 7 | ymQmczGEyZvOBDsZhtCif54o5OoO0BjhODNT8OWec9RT87n6RkH58MHlOi8xsPxQ 8 | 9n5U1CN/h2DyQF3aRKunEFCgtwPKWSjG+J/TAI9i0aSENXPiR8wjTrjg79s8Ehuj 9 | NN8Wk6rKLU3sepG3GIMID5vLsVa2t9xqn562sP95Ee+Xp2YX3z7oYK99QCJdzacw 10 | alhMHob1GCEKjDyxsD2IFRi7Dysiutfyzy3pMo6NALxFrwKVhWX0L4zVFIsI6JlV 11 | dK8dHmDk0MRSqgB9sWXvEfSTXADEe8rncFSFpFz4Z8RNLmn5YSzQJzokNn41DUCP 12 | dZTlTkcGTqvn5NqoY4sOV8rkFbgmTcqyijV/sebPjxCbJNcNmaSWa9FJ5IjRTpzM 13 | 38wLmxn+eKGK68n2JB3P7JP6LtsBShQEpXAF3rFfyNsP1bjquvGZVSjV8w/UwPE4 14 | kV5eq3j3D4913Zfxvzjp6PEmhStG0EQtIXvx/TRoYpaNWypIgZdbkZQp1HUIQL15 15 | D2Web4nazP3so1FC3ZgbrJZ2ozoadjLMp49NcSFdh+WRyVKuo0DIqR0zaiAzzf2D 16 | G1q7TLKimM3XBMUCAwEAAaNIMEYwCQYDVR0TBAIwADALBgNVHQ8EBAMCBeAwLAYD 17 | VR0RBCUwI4IJbG9jYWxob3N0hwR/AAABhxAAAAAAAAAAAAAAAAAAAAABMA0GCSqG 18 | SIb3DQEBCwUAA4ICAQAvC5M1JFc21WVSLPvE2iVbt4HmirO3EENdDqs+rTYG5VJG 19 | iE5ZuI6h/LjS5ptTfKovXQKaMr3pwp1pLMd/9q+6ZR1Hs9Z2wF6OZan4sb0uT32Y 20 | 1KGlj86QMiiSLdrJ/1Z9JHskHYNCep1ZTsUhGk0qqiNv+G3K2y7ZpvrT/xlnYMth 21 | KLTuSVUwM8BBEPrCRLoXuaEy0LnvMvMVepIfP8tnMIL6zqmj3hXMPe4r4OFV/C5o 22 | XX25bC7GyuPWIRYn2OWP92J1CODZD1rGRoDtmvqrQpHdeX9RYcKH0ZLZoIf5L3Hf 23 | pPUtVkw3QGtjvKeG3b9usxaV9Od2Z08vKKk1PRkXFe8gqaeyicK7YVIOMTSuspAf 24 | JeJEHns6Hg61Exbo7GwdX76xlmQ/Z43E9BPHKgLyZ9WuJ0cysqN4aCyvS9yws9to 25 | ki7iMZqJUsmE2o09n9VaEsX6uQANZtLjI9wf+IgJuueDTNrkzQkhU7pbaPMsSG40 26 | AgGY/y4BR0H8sbhNnhqtZH7RcXV9VCJoPBAe+YiuXRiXyZHWxwBRyBE3e7g4MKHg 27 | hrWtaWUAs7gbavHwjqgU63iVItDSk7t4fCiEyObjK09AaNf2DjjaSGf8YGza4bNy 28 | BjYinYJ6/eX//gp+abqfocFbBP7D9zRDgMIbVmX/Ey6TghKiLkZOdbzcpO4Wgg== 29 | -----END CERTIFICATE----- 30 | -------------------------------------------------------------------------------- /tests/testserver/index.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright 2017 Google Inc. All rights reserved. 3 | * Modifications copyright (c) Microsoft Corporation. 4 | * 5 | * Licensed under the Apache License, Version 2.0 (the "License"); 6 | * you may not use this file except in compliance with the License. 7 | * You may obtain a copy of the License at 8 | * 9 | * http://www.apache.org/licenses/LICENSE-2.0 10 | * 11 | * Unless required by applicable law or agreed to in writing, software 12 | * distributed under the License is distributed on an "AS IS" BASIS, 13 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 14 | * See the License for the specific language governing permissions and 15 | * limitations under the License. 16 | */ 17 | 18 | import fs from 'fs'; 19 | import url from 'node:url'; 20 | import http from 'http'; 21 | import https from 'https'; 22 | import path from 'path'; 23 | import debug from 'debug'; 24 | 25 | const fulfillSymbol = Symbol('fulfil callback'); 26 | const rejectSymbol = Symbol('reject callback'); 27 | 28 | // NOTE: Can be removed when we drop Node.js 18 support and changed to import.meta.filename. 29 | const __filename = url.fileURLToPath(import.meta.url); 30 | 31 | export class TestServer { 32 | private _server: http.Server; 33 | readonly debugServer: any; 34 | private _routes = new Map any>(); 35 | private _csp = new Map(); 36 | private _extraHeaders = new Map(); 37 | private _requestSubscribers = new Map>(); 38 | readonly PORT: number; 39 | readonly PREFIX: string; 40 | readonly CROSS_PROCESS_PREFIX: string; 41 | readonly HELLO_WORLD: string; 42 | 43 | static async create(port: number): Promise { 44 | const server = new TestServer(port); 45 | await new Promise(x => server._server.once('listening', x)); 46 | return server; 47 | } 48 | 49 | static async createHTTPS(port: number): Promise { 50 | const server = new TestServer(port, { 51 | key: await fs.promises.readFile(path.join(path.dirname(__filename), 'key.pem')), 52 | cert: await fs.promises.readFile(path.join(path.dirname(__filename), 'cert.pem')), 53 | passphrase: 'aaaa', 54 | }); 55 | await new Promise(x => server._server.once('listening', x)); 56 | return server; 57 | } 58 | 59 | constructor(port: number, sslOptions?: object) { 60 | if (sslOptions) 61 | this._server = https.createServer(sslOptions, this._onRequest.bind(this)); 62 | else 63 | this._server = http.createServer(this._onRequest.bind(this)); 64 | this._server.listen(port); 65 | this.debugServer = debug('pw:testserver'); 66 | 67 | const cross_origin = '127.0.0.1'; 68 | const same_origin = 'localhost'; 69 | const protocol = sslOptions ? 'https' : 'http'; 70 | this.PORT = port; 71 | this.PREFIX = `${protocol}://${same_origin}:${port}/`; 72 | this.CROSS_PROCESS_PREFIX = `${protocol}://${cross_origin}:${port}/`; 73 | this.HELLO_WORLD = `${this.PREFIX}hello-world`; 74 | } 75 | 76 | setCSP(path: string, csp: string) { 77 | this._csp.set(path, csp); 78 | } 79 | 80 | setExtraHeaders(path: string, object: Record) { 81 | this._extraHeaders.set(path, object); 82 | } 83 | 84 | async stop() { 85 | this.reset(); 86 | await new Promise(x => this._server.close(x)); 87 | } 88 | 89 | route(path: string, handler: (request: http.IncomingMessage, response: http.ServerResponse) => any) { 90 | this._routes.set(path, handler); 91 | } 92 | 93 | setContent(path: string, content: string, mimeType: string) { 94 | this.route(path, (req, res) => { 95 | res.writeHead(200, { 'Content-Type': mimeType }); 96 | res.end(mimeType === 'text/html' ? `${content}` : content); 97 | }); 98 | } 99 | 100 | redirect(from: string, to: string) { 101 | this.route(from, (req, res) => { 102 | const headers = this._extraHeaders.get(req.url!) || {}; 103 | res.writeHead(302, { ...headers, location: to }); 104 | res.end(); 105 | }); 106 | } 107 | 108 | waitForRequest(path: string): Promise { 109 | let promise = this._requestSubscribers.get(path); 110 | if (promise) 111 | return promise; 112 | let fulfill, reject; 113 | promise = new Promise((f, r) => { 114 | fulfill = f; 115 | reject = r; 116 | }); 117 | promise[fulfillSymbol] = fulfill; 118 | promise[rejectSymbol] = reject; 119 | this._requestSubscribers.set(path, promise); 120 | return promise; 121 | } 122 | 123 | reset() { 124 | this._routes.clear(); 125 | this._csp.clear(); 126 | this._extraHeaders.clear(); 127 | this._server.closeAllConnections(); 128 | const error = new Error('Static Server has been reset'); 129 | for (const subscriber of this._requestSubscribers.values()) 130 | subscriber[rejectSymbol].call(null, error); 131 | this._requestSubscribers.clear(); 132 | 133 | this.setContent('/favicon.ico', '', 'image/x-icon'); 134 | 135 | this.setContent('/', ``, 'text/html'); 136 | 137 | this.setContent('/hello-world', ` 138 | Title 139 | Hello, world! 140 | `, 'text/html'); 141 | } 142 | 143 | _onRequest(request: http.IncomingMessage, response: http.ServerResponse) { 144 | request.on('error', error => { 145 | if ((error as any).code === 'ECONNRESET') 146 | response.end(); 147 | else 148 | throw error; 149 | }); 150 | (request as any).postBody = new Promise(resolve => { 151 | const chunks: Buffer[] = []; 152 | request.on('data', chunk => { 153 | chunks.push(chunk); 154 | }); 155 | request.on('end', () => resolve(Buffer.concat(chunks))); 156 | }); 157 | const path = request.url || '/'; 158 | this.debugServer(`request ${request.method} ${path}`); 159 | // Notify request subscriber. 160 | if (this._requestSubscribers.has(path)) { 161 | this._requestSubscribers.get(path)![fulfillSymbol].call(null, request); 162 | this._requestSubscribers.delete(path); 163 | } 164 | const handler = this._routes.get(path); 165 | if (handler) { 166 | handler.call(null, request, response); 167 | } else { 168 | response.writeHead(404); 169 | response.end(); 170 | } 171 | } 172 | } 173 | -------------------------------------------------------------------------------- /tests/testserver/key.pem: -------------------------------------------------------------------------------- 1 | -----BEGIN PRIVATE KEY----- 2 | MIIJQgIBADANBgkqhkiG9w0BAQEFAASCCSwwggkoAgEAAoICAQCttL32qMpycevk 3 | bRlzbGENo5meNC8VuKrF/+dte3/tZqqvLOlUq7sBPVqKcG+48Sjsh/0OFWd8X//a 4 | kaXSBtTfU5Tj+avKZCZzMYTJm84EOxmG0KJ/nijk6g7QGOE4M1Pw5Z5z1FPzufpG 5 | QfnwweU6LzGw/FD2flTUI3+HYPJAXdpEq6cQUKC3A8pZKMb4n9MAj2LRpIQ1c+JH 6 | zCNOuODv2zwSG6M03xaTqsotTex6kbcYgwgPm8uxVra33Gqfnraw/3kR75enZhff 7 | Puhgr31AIl3NpzBqWEwehvUYIQqMPLGwPYgVGLsPKyK61/LPLekyjo0AvEWvApWF 8 | ZfQvjNUUiwjomVV0rx0eYOTQxFKqAH2xZe8R9JNcAMR7yudwVIWkXPhnxE0uaflh 9 | LNAnOiQ2fjUNQI91lOVORwZOq+fk2qhjiw5XyuQVuCZNyrKKNX+x5s+PEJsk1w2Z 10 | pJZr0UnkiNFOnMzfzAubGf54oYrryfYkHc/sk/ou2wFKFASlcAXesV/I2w/VuOq6 11 | 8ZlVKNXzD9TA8TiRXl6rePcPj3Xdl/G/OOno8SaFK0bQRC0he/H9NGhilo1bKkiB 12 | l1uRlCnUdQhAvXkPZZ5vidrM/eyjUULdmBuslnajOhp2Msynj01xIV2H5ZHJUq6j 13 | QMipHTNqIDPN/YMbWrtMsqKYzdcExQIDAQABAoICAGqXttpdyZ1g+vg5WpzRrNzJ 14 | v8KtExepMmI+Hq24U1BC6AqG7MfgeejQ1XaOeIBsvEgpSsgRqmdQIZjmN3Mibg59 15 | I6ih1SFlQ5L8mBd/XHSML6Xi8VSOoVmXp29bVRk/pgr1XL6HVN0DCumCIvXyhc+m 16 | lj+dFbGs5DEpd2CDxSRqcz4gd2wzjevAj7MWqsJ2kOyPEHzFD7wdWIXmZuQv3xhQ 17 | 2BPkkcon+5qx+07BupOcR1brUU8Cs4QnSgiZYXSB2GnU215+P/mhVJTR7ZcnGRz5 18 | +cXxCmy3sj4pYs1juS1FMWSM3azUeDVeqvks+vrXmXpEr5H79mbmlwo8/hMPwNDO 19 | 07HRZwa8T01aT9EYVm0lIOYjMF/2f6j6cu2apJtjXICOksR2HefRBVXQirOxRHma 20 | 9XAYfNkZ/2164ZbgFmJv9khFnegPEuth9tLVdFIeGSmsG0aX9tH63zGT2NROyyLc 21 | QXPqsDl2CxCYPRs2oiGkM9dnfP1wAOp96sq42GIuN7ykfqfRnwAIvvnLKvyCq1vR 22 | pIno3CIX6vnzt+1/Hrmv13b0L6pJPitpXwKWHv9zJKBTpN8HEzP3Qmth2Ef60/7/ 23 | CBo1PVTd1A6zcU7816flg7SCY+Vk+OxVHV3dGBIIqN9SfrQ8BPcOl6FNV5Anbrnv 24 | CpSw+LzH9n5xympDnk0BAoIBAQDjenvDfCnrNVeqx8+sYaYey4/WPVLXOQhREvRY 25 | oOtX9eqlNSi20+Wl+iuXmyj8wdHrDET7rfjCbpDQ7u105yzLw4gy4qIRDKZ1nE45 26 | YX+tm8mZgBqRnTp0DoGOArqmp3IKXJtUYmpbTz9tOfY7Usb1o1epb4winEB+Pl+8 27 | mgXOEo8xvWBzKeRA7tE73V64Mwbvbo9Ff2EguhXweQP29yBkEjT4iViayuHUmyPt 28 | hOVSMj2oFQuQGPdhAk7nUXojSGK/Zas/AGpH9CHH9De0h4m08vd3oM4vj0HwzgjU 29 | Co9aRa9SAH7EiaocOTcjDRPxWdZPHhxmrVRIYlF0MNmOAkXJAoIBAQDDfEqu4sNi 30 | pq74VXVatQqhzCILZo+o48bdgEjF7mF99mqPj8rwIDrEoEriDK861kenLc3vWKRY 31 | 5wh1iX3S896re9kUMoxx6p4heYTcsOJ9BbkcpT8bJPZx9gBJb4jJENeVf1exf6sG 32 | RhFnulpzReRRaUjX2yAkyUPfc8YcUt+Nalrg+2W0fzeLCUpABCAcj2B1Vv7qRZHj 33 | oEtlCV5Nz+iMhrwIa16g9c8wGt5DZb4PI+VIJ6EYkdsjhgqIF0T/wDq9/habGBPo 34 | mHN+/DX3hCJWN2QgoVGJskHGt0zDMgiEgXfLZ2Grl02vQtq+mW2O2vGVeUd9Y5Ew 35 | RUiY4bSRTrUdAoIBAHxL1wiP9c/By+9TUtScXssA681ioLtdPIAgXUd4VmAvzVEM 36 | ZPzRd/BjbCJg89p4hZ1rjN4Ax6ZmB9dCVpnEH6QPaYJ0d53dTa+CAvQzpDJWp6eq 37 | adobEW+M5ZmVQCwD3rpus6k+RWMzQDMMstDjgDeEU0gP3YCj5FGW/3TsrDNXzMqe 38 | 8e67ey9Hzyho43K+3xFBViPhYE8jnw1Q8quliRtlH3CWi8W5CgDD7LPCJBPvw+Tt 39 | 6u2H1tQ5EKgwyw4wZVSz1wiLz4cVjMfXWADa9pHbGQFS6pbuLlfIHObQBliLLysd 40 | ficiGcNmOAx8/uKn9gQxLc+k8iLDJkLY1mdUMpECggEAJLl87k37ltTpmg2z9k58 41 | qNjIrIugAYKJIaOwCD84YYmhi0bgQSxM3hOe/ciUQuFupKGeRpDIj0sX87zYvoDC 42 | HEUwCvNUHzKMco15wFwasJIarJ7+tALFqbMlaqZhdCSN27AIsXfikVMogewoge9n 43 | bUPyQ1sPNtn4vknptfh7tv18BTg1aytbK+ua31vnDHaDEIg/a5OWTMUYZOrVpJii 44 | f4PwX0SMioCjY84oY1EB26ZKtLt9MDh2ir3rzJVSiRl776WEaa6kTtYVHI4VNWLF 45 | cJ0HWnnz74JliQd2jFUh9IK+FqBdYPcTyREuNxBr3KKVMBeQrqW96OubL913JrU6 46 | oQKCAQEA0yzORUouT0yleWs7RmzBlT9OLD/3cBYJMf/r1F8z8OQjB8fU1jKbO1Cs 47 | q4l+o9FmI+eHkgc3xbEG0hahOFWm/hTTli9vzksxurgdawZELThRkK33uTU9pKla 48 | Okqx3Ru/iMOW2+DQUx9UB+jK+hSAgq4gGqLeJVyaBerIdLQLlvqxrwSxjvvj+wJC 49 | Y66mgRzdCi6VDF1vV0knCrQHK6tRwcPozu/k4zjJzvdbMJnKEy2S7Vh6vO8lEPJm 50 | MQtaHPpmz+F4z14b9unNIiSbHO60Q4O+BwIBCzxApQQbFg63vBLYYwEMRd7hh92s 51 | ZkZVSOEp+sYBf/tmptlKr49nO+dTjQ== 52 | -----END PRIVATE KEY----- 53 | -------------------------------------------------------------------------------- /tests/testserver/san.cnf: -------------------------------------------------------------------------------- 1 | # openssl req -new -x509 -days 3650 -key key.pem -out cert.pem -config san.cnf -extensions v3_req 2 | 3 | [req] 4 | distinguished_name = req_distinguished_name 5 | req_extensions = v3_req 6 | prompt = no 7 | 8 | [req_distinguished_name] 9 | CN = playwright-test 10 | 11 | [v3_req] 12 | basicConstraints = CA:FALSE 13 | keyUsage = nonRepudiation, digitalSignature, keyEncipherment 14 | subjectAltName = @alt_names 15 | 16 | [alt_names] 17 | DNS.1 = localhost 18 | IP.1 = 127.0.0.1 19 | IP.2 = ::1 20 | -------------------------------------------------------------------------------- /tests/trace.spec.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright (c) Microsoft Corporation. 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | import fs from 'fs'; 18 | import path from 'path'; 19 | 20 | import { test, expect } from './fixtures.js'; 21 | 22 | test('check that trace is saved', async ({ startClient, server }, testInfo) => { 23 | const outputDir = testInfo.outputPath('output'); 24 | 25 | const { client } = await startClient({ 26 | args: ['--save-trace', `--output-dir=${outputDir}`], 27 | }); 28 | 29 | expect(await client.callTool({ 30 | name: 'browser_navigate', 31 | arguments: { url: server.HELLO_WORLD }, 32 | })).toContainTextContent(`Navigate to http://localhost`); 33 | 34 | expect(fs.existsSync(path.join(outputDir, 'traces', 'trace.trace'))).toBeTruthy(); 35 | }); 36 | -------------------------------------------------------------------------------- /tests/wait.spec.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright (c) Microsoft Corporation. 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | import { test, expect } from './fixtures.js'; 18 | 19 | test('browser_wait_for(text)', async ({ client, server }) => { 20 | server.setContent('/', ` 21 | 28 | 29 | 30 |
Text to disappear
31 | 32 | `, 'text/html'); 33 | 34 | await client.callTool({ 35 | name: 'browser_navigate', 36 | arguments: { url: server.PREFIX }, 37 | }); 38 | 39 | await client.callTool({ 40 | name: 'browser_click', 41 | arguments: { 42 | element: 'Click me', 43 | ref: 'e2', 44 | }, 45 | }); 46 | 47 | expect(await client.callTool({ 48 | name: 'browser_wait_for', 49 | arguments: { text: 'Text to appear' }, 50 | })).toContainTextContent(`- generic [ref=e3]: Text to appear`); 51 | }); 52 | 53 | test('browser_wait_for(textGone)', async ({ client, server }) => { 54 | server.setContent('/', ` 55 | 62 | 63 | 64 |
Text to disappear
65 | 66 | `, 'text/html'); 67 | 68 | await client.callTool({ 69 | name: 'browser_navigate', 70 | arguments: { url: server.PREFIX }, 71 | }); 72 | 73 | await client.callTool({ 74 | name: 'browser_click', 75 | arguments: { 76 | element: 'Click me', 77 | ref: 'e2', 78 | }, 79 | }); 80 | 81 | expect(await client.callTool({ 82 | name: 'browser_wait_for', 83 | arguments: { textGone: 'Text to disappear' }, 84 | })).toContainTextContent(`- generic [ref=e3]: Text to appear`); 85 | }); 86 | -------------------------------------------------------------------------------- /tests/webdriver.spec.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright (c) Microsoft Corporation. 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | import { test, expect } from './fixtures.js'; 18 | 19 | test('do not falsely advertise user agent as a test driver', async ({ client, server, mcpBrowser }) => { 20 | test.skip(mcpBrowser === 'firefox'); 21 | test.skip(mcpBrowser === 'webkit'); 22 | server.route('/', (req, res) => { 23 | res.writeHead(200, { 'Content-Type': 'text/html' }); 24 | res.end(` 25 | 26 | 29 | `); 30 | }); 31 | 32 | expect(await client.callTool({ 33 | name: 'browser_navigate', 34 | arguments: { 35 | url: server.PREFIX, 36 | }, 37 | })).toContainTextContent('webdriver: false'); 38 | }); 39 | -------------------------------------------------------------------------------- /tsconfig.all.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "include": ["**/*.ts", "**/*.js"], 4 | } 5 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ESNext", 4 | "esModuleInterop": true, 5 | "moduleResolution": "nodenext", 6 | "strict": true, 7 | "module": "NodeNext", 8 | "rootDir": "src", 9 | "outDir": "./lib", 10 | "resolveJsonModule": true 11 | }, 12 | "include": [ 13 | "src", 14 | ], 15 | } 16 | -------------------------------------------------------------------------------- /utils/copyright.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright (c) Microsoft Corporation. 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | -------------------------------------------------------------------------------- /utils/generate-links.js: -------------------------------------------------------------------------------- 1 | const config = JSON.stringify({ name: 'playwright', command: 'npx', args: ["@playwright/mcp@latest"] }); 2 | const urlForWebsites = `vscode:mcp/install?${encodeURIComponent(config)}`; 3 | // Github markdown does not allow linking to `vscode:` directly, so you can use our redirect: 4 | const urlForGithub = `https://insiders.vscode.dev/redirect?url=${encodeURIComponent(urlForWebsites)}`; 5 | 6 | console.log(urlForGithub); -------------------------------------------------------------------------------- /utils/update-readme.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | /** 3 | * Copyright (c) Microsoft Corporation. 4 | * 5 | * Licensed under the Apache License, Version 2.0 (the "License"); 6 | * you may not use this file except in compliance with the License. 7 | * You may obtain a copy of the License at 8 | * 9 | * http://www.apache.org/licenses/LICENSE-2.0 10 | * 11 | * Unless required by applicable law or agreed to in writing, software 12 | * distributed under the License is distributed on an "AS IS" BASIS, 13 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 14 | * See the License for the specific language governing permissions and 15 | * limitations under the License. 16 | */ 17 | // @ts-check 18 | 19 | import fs from 'node:fs' 20 | import path from 'node:path' 21 | import url from 'node:url' 22 | import zodToJsonSchema from 'zod-to-json-schema' 23 | 24 | import commonTools from '../lib/tools/common.js'; 25 | import consoleTools from '../lib/tools/console.js'; 26 | import dialogsTools from '../lib/tools/dialogs.js'; 27 | import filesTools from '../lib/tools/files.js'; 28 | import installTools from '../lib/tools/install.js'; 29 | import keyboardTools from '../lib/tools/keyboard.js'; 30 | import navigateTools from '../lib/tools/navigate.js'; 31 | import networkTools from '../lib/tools/network.js'; 32 | import pdfTools from '../lib/tools/pdf.js'; 33 | import snapshotTools from '../lib/tools/snapshot.js'; 34 | import tabsTools from '../lib/tools/tabs.js'; 35 | import screenshotTools from '../lib/tools/screenshot.js'; 36 | import testTools from '../lib/tools/testing.js'; 37 | import visionTools from '../lib/tools/vision.js'; 38 | import waitTools from '../lib/tools/wait.js'; 39 | import { execSync } from 'node:child_process'; 40 | 41 | const categories = { 42 | 'Interactions': [ 43 | ...snapshotTools, 44 | ...keyboardTools(true), 45 | ...waitTools(true), 46 | ...filesTools(true), 47 | ...dialogsTools(true), 48 | ], 49 | 'Navigation': [ 50 | ...navigateTools(true), 51 | ], 52 | 'Resources': [ 53 | ...screenshotTools, 54 | ...pdfTools, 55 | ...networkTools, 56 | ...consoleTools, 57 | ], 58 | 'Utilities': [ 59 | ...installTools, 60 | ...commonTools(true), 61 | ], 62 | 'Tabs': [ 63 | ...tabsTools(true), 64 | ], 65 | 'Testing': [ 66 | ...testTools, 67 | ], 68 | 'Vision mode': [ 69 | ...visionTools, 70 | ...keyboardTools(), 71 | ...waitTools(false), 72 | ...filesTools(false), 73 | ...dialogsTools(false), 74 | ], 75 | }; 76 | 77 | // NOTE: Can be removed when we drop Node.js 18 support and changed to import.meta.filename. 78 | const __filename = url.fileURLToPath(import.meta.url); 79 | 80 | /** 81 | * @param {import('../src/tools/tool.js').ToolSchema} tool 82 | * @returns {string[]} 83 | */ 84 | function formatToolForReadme(tool) { 85 | const lines = /** @type {string[]} */ ([]); 86 | lines.push(``); 87 | lines.push(``); 88 | lines.push(`- **${tool.name}**`); 89 | lines.push(` - Title: ${tool.title}`); 90 | lines.push(` - Description: ${tool.description}`); 91 | 92 | const inputSchema = /** @type {any} */ (zodToJsonSchema(tool.inputSchema || {})); 93 | const requiredParams = inputSchema.required || []; 94 | if (inputSchema.properties && Object.keys(inputSchema.properties).length) { 95 | lines.push(` - Parameters:`); 96 | Object.entries(inputSchema.properties).forEach(([name, param]) => { 97 | const optional = !requiredParams.includes(name); 98 | const meta = /** @type {string[]} */ ([]); 99 | if (param.type) 100 | meta.push(param.type); 101 | if (optional) 102 | meta.push('optional'); 103 | lines.push(` - \`${name}\` ${meta.length ? `(${meta.join(', ')})` : ''}: ${param.description}`); 104 | }); 105 | } else { 106 | lines.push(` - Parameters: None`); 107 | } 108 | lines.push(` - Read-only: **${tool.type === 'readOnly'}**`); 109 | lines.push(''); 110 | return lines; 111 | } 112 | 113 | /** 114 | * @param {string} content 115 | * @param {string} startMarker 116 | * @param {string} endMarker 117 | * @param {string[]} generatedLines 118 | * @returns {Promise} 119 | */ 120 | async function updateSection(content, startMarker, endMarker, generatedLines) { 121 | const startMarkerIndex = content.indexOf(startMarker); 122 | const endMarkerIndex = content.indexOf(endMarker); 123 | if (startMarkerIndex === -1 || endMarkerIndex === -1) 124 | throw new Error('Markers for generated section not found in README'); 125 | 126 | return [ 127 | content.slice(0, startMarkerIndex + startMarker.length), 128 | '', 129 | generatedLines.join('\n'), 130 | '', 131 | content.slice(endMarkerIndex), 132 | ].join('\n'); 133 | } 134 | 135 | /** 136 | * @param {string} content 137 | * @returns {Promise} 138 | */ 139 | async function updateTools(content) { 140 | console.log('Loading tool information from compiled modules...'); 141 | 142 | const totalTools = Object.values(categories).flat().length; 143 | console.log(`Found ${totalTools} tools`); 144 | 145 | const generatedLines = /** @type {string[]} */ ([]); 146 | for (const [category, categoryTools] of Object.entries(categories)) { 147 | generatedLines.push(`
\n${category}`); 148 | generatedLines.push(''); 149 | for (const tool of categoryTools) 150 | generatedLines.push(...formatToolForReadme(tool.schema)); 151 | generatedLines.push(`
`); 152 | generatedLines.push(''); 153 | } 154 | 155 | const startMarker = ``; 156 | const endMarker = ``; 157 | return updateSection(content, startMarker, endMarker, generatedLines); 158 | } 159 | 160 | /** 161 | * @param {string} content 162 | * @returns {Promise} 163 | */ 164 | async function updateOptions(content) { 165 | console.log('Listing options...'); 166 | const output = execSync('node cli.js --help'); 167 | const lines = output.toString().split('\n'); 168 | const firstLine = lines.findIndex(line => line.includes('--version')); 169 | lines.splice(0, firstLine + 1); 170 | const lastLine = lines.findIndex(line => line.includes('--help')); 171 | lines.splice(lastLine); 172 | const startMarker = ``; 173 | const endMarker = ``; 174 | return updateSection(content, startMarker, endMarker, [ 175 | '```', 176 | '> npx @playwright/mcp@latest --help', 177 | ...lines, 178 | '```', 179 | ]); 180 | } 181 | 182 | async function updateReadme() { 183 | const readmePath = path.join(path.dirname(__filename), '..', 'README.md'); 184 | const readmeContent = await fs.promises.readFile(readmePath, 'utf-8'); 185 | const withTools = await updateTools(readmeContent); 186 | const withOptions = await updateOptions(withTools); 187 | await fs.promises.writeFile(readmePath, withOptions, 'utf-8'); 188 | console.log('README updated successfully'); 189 | } 190 | 191 | updateReadme().catch(err => { 192 | console.error('Error updating README:', err); 193 | process.exit(1); 194 | }); 195 | --------------------------------------------------------------------------------