diff --git a/.github/workflows/publish_extension.yml b/.github/workflows/publish_extension.yml new file mode 100644 index 0000000000000..52eabcb15a87b --- /dev/null +++ b/.github/workflows/publish_extension.yml @@ -0,0 +1,76 @@ +name: Publish Extension to Chrome Web Store +on: + workflow_dispatch: + +jobs: + publish-extension-chrome-web-store: + runs-on: ubuntu-latest + environment: allow-publishing-extension-to-cws + steps: + - uses: actions/checkout@v5 + - uses: actions/setup-node@v5 + with: + node-version: 20 + cache: 'npm' + - name: Install dependencies + run: npm ci + - name: Build extension + run: npm run build-extension + - name: Package extension + working-directory: ./packages/extension + run: | + cd dist + zip -r ../extension.zip . + - name: Get access token + id: auth + run: | + ACCESS_TOKEN=$(curl -s -X POST "https://oauth2.googleapis.com/token" \ + -d "client_id=${{ secrets.CHROME_CLIENT_ID }}" \ + -d "client_secret=${{ secrets.CHROME_CLIENT_SECRET }}" \ + -d "refresh_token=${{ secrets.CHROME_REFRESH_TOKEN }}" \ + -d "grant_type=refresh_token" | jq -r '.access_token') + if [ "$ACCESS_TOKEN" = "null" ] || [ -z "$ACCESS_TOKEN" ]; then + echo "Failed to obtain access token" + exit 1 + fi + echo "::add-mask::$ACCESS_TOKEN" + echo "access_token=$ACCESS_TOKEN" >> $GITHUB_OUTPUT + - name: Upload to Chrome Web Store + run: | + RESPONSE=$(curl -s -w "\n%{http_code}" \ + -X PUT "https://www.googleapis.com/upload/chromewebstore/v1.1/items/${{ secrets.CHROME_EXTENSION_ID }}" \ + -H "Authorization: Bearer ${{ steps.auth.outputs.access_token }}" \ + -H "x-goog-api-version: 2" \ + -T ./packages/extension/extension.zip) + HTTP_CODE=$(echo "$RESPONSE" | tail -1) + BODY=$(echo "$RESPONSE" | head -n -1) + echo "$BODY" + if [ "$HTTP_CODE" -ne 200 ]; then + echo "Upload failed with HTTP $HTTP_CODE" + exit 1 + fi + STATUS=$(echo "$BODY" | jq -r '.uploadState') + if [ "$STATUS" != "SUCCESS" ]; then + echo "Upload state: $STATUS" + exit 1 + fi + - name: Publish to Chrome Web Store + run: | + RESPONSE=$(curl -s -w "\n%{http_code}" \ + -X POST "https://www.googleapis.com/chromewebstore/v1.1/items/${{ secrets.CHROME_EXTENSION_ID }}/publish" \ + -H "Authorization: Bearer ${{ steps.auth.outputs.access_token }}" \ + -H "x-goog-api-version: 2" \ + -H "Content-Length: 0") + HTTP_CODE=$(echo "$RESPONSE" | tail -1) + BODY=$(echo "$RESPONSE" | head -n -1) + echo "$BODY" + if [ "$HTTP_CODE" -ne 200 ]; then + echo "Publish failed with HTTP $HTTP_CODE" + exit 1 + fi + STATUS=$(echo "$BODY" | jq -r '.status[0]') + if [ "$STATUS" != "OK" ] && [ "$STATUS" != "PUBLISHED_WITH_FRICTION_WARNING" ]; then + echo "Publish status: $STATUS" + exit 1 + fi + echo "Extension published successfully" diff --git a/.github/workflows/tests_extension.yml b/.github/workflows/tests_extension.yml new file mode 100644 index 0000000000000..c199ec893ced8 --- /dev/null +++ b/.github/workflows/tests_extension.yml @@ -0,0 +1,49 @@ +name: Extension + +on: + push: + branches: + - main + - release-* + paths: + - 'packages/playwright-core/src/tools/**' + - '!packages/playwright-core/src/tools/dashboard/**' + - '!packages/playwright-core/src/tools/trace/**' + - 'packages/extension/**' + - 'tests/extension/**' + - '.github/workflows/tests_extension.yml' + pull_request: + branches: + - main + - release-* + paths: + - 'packages/playwright-core/src/tools/**' + - '!packages/playwright-core/src/tools/dashboard/**' + - '!packages/playwright-core/src/tools/trace/**' + - 'packages/extension/**' + - 'tests/extension/**' + - '.github/workflows/tests_extension.yml' + +env: + FORCE_COLOR: 1 + ELECTRON_SKIP_BINARY_DOWNLOAD: 1 + DEBUG: pw:mcp:error + +jobs: + test_extension: + name: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + os: [macos-latest] + runs-on: ${{ matrix.os }} + steps: + - uses: actions/checkout@v6 + - uses: actions/setup-node@v6 + with: + node-version: 22 + - run: npm ci + - run: npm run build + - run: npm run build-extension + - run: npx playwright install --with-deps + - run: npm run test-extension diff --git a/docs/src/api/class-locator.md b/docs/src/api/class-locator.md index 7b5916d418c0a..4057f6c224367 100644 --- a/docs/src/api/class-locator.md +++ b/docs/src/api/class-locator.md @@ -922,6 +922,54 @@ Locator of the element to drag to. ### option: Locator.dragTo.steps = %%-input-drag-steps-%% * since: v1.57 +## async method: Locator.drop +* since: v1.60 + +Simulate an external drag-and-drop of files or clipboard-like data onto this locator. + +**Details** + +Dispatches the native `dragenter`, `dragover`, and `drop` events at the center of the +target element with a synthetic [DataTransfer] carrying the provided files and/or data +entries. Works cross-browser by constructing the [DataTransfer] in the page context. + +If the target element's `dragover` listener does not call `preventDefault()`, the target +is considered to have rejected the drop: Playwright dispatches `dragleave` and this +method throws. + +**Usage** + +Drop a file buffer onto an upload area: + +```js +await page.locator('#dropzone').drop({ + files: { name: 'note.txt', mimeType: 'text/plain', buffer: Buffer.from('hello') }, +}); +``` + +Drop plain text and a URL together: + +```js +await page.locator('#dropzone').drop({ + data: { + 'text/plain': 'hello world', + 'text/uri-list': 'https://example.com', + }, +}); +``` + +### param: Locator.drop.payload = %%-drop-payload-%% +* since: v1.60 + +### option: Locator.drop.position = %%-input-position-%% +* since: v1.60 + +### option: Locator.drop.timeout = %%-input-timeout-%% +* since: v1.60 + +### option: Locator.drop.timeout = %%-input-timeout-js-%% +* since: v1.60 + ## async method: Locator.elementHandle * since: v1.14 * discouraged: Always prefer using [Locator]s and web assertions over [ElementHandle]s because latter are inherently racy. diff --git a/docs/src/api/params.md b/docs/src/api/params.md index faef52e2419ba..8a9d3d4322a93 100644 --- a/docs/src/api/params.md +++ b/docs/src/api/params.md @@ -134,6 +134,18 @@ Defaults to `left`. - `mimeType` <[string]> File type - `buffer` <[Buffer]> File content +## drop-payload +- `payload` <[Object]> + - `files` ?<[path]|[Array]<[path]>|[Object]|[Array]<[Object]>> + - `name` <[string]> File name + - `mimeType` <[string]> File type + - `buffer` <[Buffer]> File content + - `data` ?<[Object]<[string], [string]>> + +Data to drop onto the target. Provide `files` (file paths or in-memory buffers), `data` +(a mime-type → string map for clipboard-like content such as `text/plain`, `text/html`, +`text/uri-list`), or both. + ## input-down-up-delay - `delay` <[float]> diff --git a/package-lock.json b/package-lock.json index cf1cf2f27bd3d..29efbb5aa5bf2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1987,6 +1987,10 @@ "resolved": "packages/playwright-ct-vue", "link": true }, + "node_modules/@playwright/extension": { + "resolved": "packages/extension", + "link": true + }, "node_modules/@playwright/test": { "resolved": "packages/playwright-test", "link": true @@ -2444,6 +2448,17 @@ "@types/responselike": "^1.0.0" } }, + "node_modules/@types/chrome": { + "version": "0.0.315", + "resolved": "https://registry.npmjs.org/@types/chrome/-/chrome-0.0.315.tgz", + "integrity": "sha512-Oy1dYWkr6BCmgwBtOngLByCHstQ3whltZg7/7lubgIZEYvKobDneqplgc6LKERNRBwckFviV4UU5AZZNUFrJ4A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/filesystem": "*", + "@types/har-format": "*" + } + }, "node_modules/@types/codemirror": { "version": "5.60.16", "resolved": "https://registry.npmjs.org/@types/codemirror/-/codemirror-5.60.16.tgz", @@ -2475,6 +2490,23 @@ "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==" }, + "node_modules/@types/filesystem": { + "version": "0.0.36", + "resolved": "https://registry.npmjs.org/@types/filesystem/-/filesystem-0.0.36.tgz", + "integrity": "sha512-vPDXOZuannb9FZdxgHnqSwAG/jvdGM8Wq+6N4D/d80z+D4HWH+bItqsZaVRQykAn6WEVeEkLm2oQigyHtgb0RA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/filewriter": "*" + } + }, + "node_modules/@types/filewriter": { + "version": "0.0.33", + "resolved": "https://registry.npmjs.org/@types/filewriter/-/filewriter-0.0.33.tgz", + "integrity": "sha512-xFU8ZXTw4gd358lb2jw25nxY9QAgqn2+bKKjKOYfNCzN4DKCFetK7sPtrlpg66Ywe3vWY9FNxprZawAh9wfJ3g==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/formidable": { "version": "2.0.6", "resolved": "https://registry.npmjs.org/@types/formidable/-/formidable-2.0.6.tgz", @@ -2484,6 +2516,13 @@ "@types/node": "*" } }, + "node_modules/@types/har-format": { + "version": "1.2.16", + "resolved": "https://registry.npmjs.org/@types/har-format/-/har-format-1.2.16.tgz", + "integrity": "sha512-fluxdy7ryD3MV6h8pTfTYpy/xQzCFC7m89nOH9y94cNqJ1mDIDPut7MnRHI3F6qRmh/cT2fUjG1MLdCNb4hE9A==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/http-cache-semantics": { "version": "4.0.4", "resolved": "https://registry.npmjs.org/@types/http-cache-semantics/-/http-cache-semantics-4.0.4.tgz", @@ -7409,6 +7448,19 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/p-map": { + "version": "7.0.4", + "resolved": "https://registry.npmjs.org/p-map/-/p-map-7.0.4.tgz", + "integrity": "sha512-tkAQEw8ysMzmkhgw8k+1U/iPhWNhykKnSk4Rd5zLoPJCuJaGRPo6YposrZgaxHKzDHdDWWZvE/Sk7hsL2X/CpQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/parent-module": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", @@ -9218,6 +9270,29 @@ } } }, + "node_modules/vite-plugin-static-copy": { + "version": "3.4.0", + "resolved": "https://registry.npmjs.org/vite-plugin-static-copy/-/vite-plugin-static-copy-3.4.0.tgz", + "integrity": "sha512-ekryzCw0ouAOE8tw4RvVL/dfqguXzumsV3FBKoKso4MQ1MUUrUXtl5RI4KpJQUNGqFEsg9kxl4EvDl02YtA9VQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "chokidar": "^3.6.0", + "p-map": "^7.0.4", + "picocolors": "^1.1.1", + "tinyglobby": "^0.2.15" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/sapphi-red" + }, + "peerDependencies": { + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, "node_modules/vue": { "version": "3.5.21", "resolved": "https://registry.npmjs.org/vue/-/vue-3.5.21.tgz", @@ -9540,6 +9615,19 @@ "version": "0.0.0", "extraneous": true }, + "packages/extension": { + "name": "@playwright/extension", + "version": "0.1.0", + "license": "Apache-2.0", + "devDependencies": { + "@types/chrome": "^0.0.315", + "minimist": "^1.2.5", + "vite-plugin-static-copy": "^3.1.1" + }, + "engines": { + "node": ">=18" + } + }, "packages/html-reporter": { "version": "0.0.0" }, diff --git a/package.json b/package.json index e98d5cbd7306b..e47a444117847 100644 --- a/package.json +++ b/package.json @@ -29,6 +29,8 @@ "test": "playwright test --config=tests/library/playwright.config.ts", "test-mcp": "playwright test --config=tests/mcp/playwright.config.ts", "ctest-mcp": "playwright test --config=tests/mcp/playwright.config.ts --project=chrome", + "test-extension": "playwright test --config=tests/extension/playwright.config.ts", + "build-extension": "npm run build -w @playwright/extension", "eslint": "eslint --cache", "tsc": "tsc -p .", "doc": "node utils/doclint/cli.js", diff --git a/packages/dashboard/src/dashboard.css b/packages/dashboard/src/dashboard.css index 29b787859047f..f23c2b16b307f 100644 --- a/packages/dashboard/src/dashboard.css +++ b/packages/dashboard/src/dashboard.css @@ -154,17 +154,26 @@ padding: 0 12px; font-size: 13px; font-family: inherit; - background: var(--color-canvas-default); + background: var(--color-canvas-inset); color: var(--color-fg-default); - border: 1px solid var(--color-border-muted); + border: 1px solid var(--color-border-default); border-radius: 16px; outline: none; min-width: 0; } +:root.light-mode .omnibox { + background: var(--color-canvas-default); +} + .omnibox:focus { border-color: var(--color-accent-fg); - background: var(--color-canvas-subtle); + background: var(--color-canvas-default); + box-shadow: 0 0 0 2px var(--color-accent-muted); +} + +:root.light-mode .omnibox:focus { + background: var(--color-canvas-default); } .omnibox::placeholder { diff --git a/packages/dashboard/src/dashboard.tsx b/packages/dashboard/src/dashboard.tsx index 0de92be817b50..5910a41c335d1 100644 --- a/packages/dashboard/src/dashboard.tsx +++ b/packages/dashboard/src/dashboard.tsx @@ -28,6 +28,38 @@ import type { Tab, DashboardChannelEvents } from './dashboardChannel'; const BUTTONS = ['left', 'middle', 'right'] as const; type Mode = 'readonly' | 'interactive' | 'annotate'; +async function pickSaveWritable(suggestedName: string, description: string, mime: string, extension: string): Promise { + try { + const handle = await (window as any).showSaveFilePicker({ + suggestedName, + types: [{ description, accept: { [mime]: [extension] } }], + }); + return await handle.createWritable(); + } catch { + return null; + } +} + +function base64ToBlob(base64: string, mime: string): Blob { + return new Blob([(Uint8Array as any).fromBase64(base64)], { type: mime }); +} + +function smartUrl(input: string): string { + const value = input.trim(); + if (!value) + return value; + if (/^[a-z][a-z0-9+.-]*:\/\//i.test(value) || value.startsWith('about:') || value.startsWith('data:')) + return value; + const host = value.split(/[/?#]/, 1)[0]; + const hasDot = host.includes('.'); + const isLocalhost = /^localhost(:\d+)?$/i.test(host); + const hasPort = /:\d+$/.test(host); + const isIp = /^\d{1,3}(\.\d{1,3}){3}(:\d+)?$/.test(host); + if (hasDot || isLocalhost || hasPort || isIp) + return 'https://' + value; + return 'https://' + host + '.com' + value.slice(host.length); +} + export const Dashboard: React.FC = () => { const client = React.useContext(DashboardClientContext); const [mode, setMode] = React.useState('readonly'); @@ -36,26 +68,9 @@ export const Dashboard: React.FC = () => { const [frame, setFrame] = React.useState(); const [picking, setPicking] = React.useState(false); const [recording, setRecording] = React.useState(false); - const [screenshotIcon, setScreenshotIcon] = React.useState<'device-camera' | 'check'>('device-camera'); + const [screenshotIcon, setScreenshotIcon] = React.useState<'device-camera' | 'clippy'>('device-camera'); const [flashTick, setFlashTick] = React.useState(0); - const downloadArtifact = React.useCallback(async (id: string) => { - const appMode = window.matchMedia('(display-mode: standalone)').matches - || (window as any).__dashboardAppModeForTest === true; - if (appMode) - return await client!.saveAndReveal({ id }); - - const a = document.createElement('a'); - a.href = `/artifact/${encodeURIComponent(id)}`; - a.style.display = 'none'; - document.body.appendChild(a); - try { - a.click(); - } finally { - a.remove(); - } - }, [client]); - const displayRef = React.useRef(null); const screenRef = React.useRef(null); const toolbarRef = React.useRef(null); @@ -259,9 +274,7 @@ export const Dashboard: React.FC = () => { function onOmniboxKeyDown(e: React.KeyboardEvent) { if (e.key === 'Enter') { - let value = (e.target as HTMLInputElement).value.trim(); - if (!/^https?:\/\//i.test(value)) - value = 'https://' + value; + const value = smartUrl((e.target as HTMLInputElement).value); setUrl(value); client?.navigate({ url: value }); e.currentTarget.blur(); @@ -315,9 +328,18 @@ export const Dashboard: React.FC = () => { if (!client) return; if (recording) { - const { id } = await client.stopRecording(); + const writable = await pickSaveWritable(`playwright-recording-${Date.now()}.webm`, 'WebM Video', 'video/webm', '.webm'); + if (!writable) + return; setRecording(false); - await downloadArtifact(id); + const { streamId } = await client.stopRecording(); + while (true) { + const { data, eof } = await client.readStream({ streamId }); + if (eof) + break; + await writable.write(base64ToBlob(data, 'video/webm')); + } + await writable.close(); } else { await client.startRecording(); setRecording(true); @@ -327,15 +349,19 @@ export const Dashboard: React.FC = () => { { if (!client) return; - const { id } = await client.screenshot(); - await downloadArtifact(id); - setScreenshotIcon('check'); + const writable = await pickSaveWritable(`playwright-screenshot-${Date.now()}.png`, 'PNG Image', 'image/png', '.png'); + if (!writable) + return; + const data = await client.screenshot(); + await writable.write(base64ToBlob(data, 'image/png')); + await writable.close(); + setScreenshotIcon('clippy'); setTimeout(() => setScreenshotIcon('device-camera'), 3000); }} /> diff --git a/packages/dashboard/src/dashboardChannel.ts b/packages/dashboard/src/dashboardChannel.ts index 54cd26b8bbc9e..bbde6fbb0ed36 100644 --- a/packages/dashboard/src/dashboardChannel.ts +++ b/packages/dashboard/src/dashboardChannel.ts @@ -45,7 +45,7 @@ export interface DashboardChannel { closeSession(params: { browser: string }): Promise; deleteSessionData(params: { browser: string }): Promise; setVisible(params: { visible: boolean }): Promise; - saveAndReveal(params: { id: string }): Promise; + reveal(params: { path: string }): Promise; navigate(params: { url: string }): Promise; back(): Promise; @@ -60,8 +60,9 @@ export interface DashboardChannel { pickLocator(): Promise; cancelPickLocator(): Promise; startRecording(): Promise; - stopRecording(): Promise<{ id: string }>; - screenshot(): Promise<{ id: string }>; + stopRecording(): Promise<{ streamId: string }>; + readStream(params: { streamId: string }): Promise<{ data: string; eof: boolean }>; + screenshot(): Promise; on(event: K, listener: (params: DashboardChannelEvents[K]) => void): void; off(event: K, listener: (params: DashboardChannelEvents[K]) => void): void; diff --git a/packages/extension/.gitignore b/packages/extension/.gitignore new file mode 100644 index 0000000000000..849ddff3b7ec9 --- /dev/null +++ b/packages/extension/.gitignore @@ -0,0 +1 @@ +dist/ diff --git a/packages/extension/README.md b/packages/extension/README.md new file mode 100644 index 0000000000000..a385bbb4bc98d --- /dev/null +++ b/packages/extension/README.md @@ -0,0 +1,70 @@ +# Playwright MCP Chrome Extension + +## Introduction + +The Playwright MCP Chrome Extension allows you to connect to pages in your existing browser and leverage the state of your default user profile. This means the AI assistant can interact with websites where you're already logged in, using your existing cookies, sessions, and browser state, providing a seamless experience without requiring separate authentication or setup. + +## Prerequisites + +- Chrome/Edge/Chromium browser + +## Installation Steps + +### Install the Extension + +Install [Playwright Extension](https://chromewebstore.google.com/detail/playwright-mcp-bridge/mmlmfjhmonkocbjadbfplnigmagldckm) from the Chrome Web Store. + +### Configure Playwright MCP server + +Configure Playwright MCP server to connect to the browser using the extension by passing the `--extension` option when running the MCP server: + +```json +{ + "mcpServers": { + "playwright-extension": { + "command": "npx", + "args": [ + "@playwright/mcp@latest", + "--extension" + ] + } + } +} +``` + +## Usage + +### Browser Tab Selection + +When the LLM interacts with the browser for the first time, it will load a page where you can select which browser tab the LLM will connect to. This allows you to control which specific page the AI assistant will interact with during the session. + +### Bypassing the Connection Approval Dialog + +By default, you'll need to approve each connection when the MCP server tries to connect to your browser. To bypass this approval dialog and allow automatic connections, you can use an authentication token. + +#### Using Your Unique Authentication Token + +1. After installing the extension, click on the extension icon or navigate to the extension's status page +2. Copy the `PLAYWRIGHT_MCP_EXTENSION_TOKEN` value displayed in the extension UI +3. Add it to your MCP server configuration: + +```json +{ + "mcpServers": { + "playwright-extension": { + "command": "npx", + "args": [ + "@playwright/mcp@latest", + "--extension" + ], + "env": { + "PLAYWRIGHT_MCP_EXTENSION_TOKEN": "your-token-here" + } + } + } +} +``` + +This token is unique to your browser profile and provides secure authentication between the MCP server and the extension. Once configured, you won't need to manually approve connections each time. + + diff --git a/packages/extension/icons/icon-128.png b/packages/extension/icons/icon-128.png new file mode 100644 index 0000000000000..c4bc8b02508b4 Binary files /dev/null and b/packages/extension/icons/icon-128.png differ diff --git a/packages/extension/icons/icon-16.png b/packages/extension/icons/icon-16.png new file mode 100644 index 0000000000000..0bab712125a75 Binary files /dev/null and b/packages/extension/icons/icon-16.png differ diff --git a/packages/extension/icons/icon-32.png b/packages/extension/icons/icon-32.png new file mode 100644 index 0000000000000..1f9a8ccb89bdb Binary files /dev/null and b/packages/extension/icons/icon-32.png differ diff --git a/packages/extension/icons/icon-48.png b/packages/extension/icons/icon-48.png new file mode 100644 index 0000000000000..ac23ef078c22f Binary files /dev/null and b/packages/extension/icons/icon-48.png differ diff --git a/packages/extension/manifest.json b/packages/extension/manifest.json new file mode 100644 index 0000000000000..992c3814c3221 --- /dev/null +++ b/packages/extension/manifest.json @@ -0,0 +1,35 @@ +{ + "manifest_version": 3, + "name": "Playwright Extension", + "version": "0.1.0", + "description": "Connect your browser to AI agents through Playwright MCP server and CLI. Enables AI-driven web testing, debugging, and automation.", + "permissions": [ + "debugger", + "activeTab", + "tabs", + "tabGroups" + ], + "host_permissions": [ + "" + ], + "background": { + "service_worker": "lib/background.mjs", + "type": "module" + }, + "action": { + "default_title": "Playwright Extension", + "default_icon": { + "16": "icons/icon-16.png", + "32": "icons/icon-32.png", + "48": "icons/icon-48.png", + "128": "icons/icon-128.png" + } + }, + "icons": { + "16": "icons/icon-16.png", + "32": "icons/icon-32.png", + "48": "icons/icon-48.png", + "128": "icons/icon-128.png" + }, + "key": "MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAwRsUUO4mmbCi4JpmrIoIw31iVW9+xUJRZ6nSzya17PQkaUPDxe1IpgM+vpd/xB6mJWlJSyE1Lj95c0sbomGfVY1M0zUeKbaRVcAb+/a6m59gNR+ubFlmTX0nK9/8fE2FpRB9D+4N5jyeIPQuASW/0oswI2/ijK7hH5NTRX8gWc/ROMSgUj7rKhTAgBrICt/NsStgDPsxRTPPJnhJ/ViJtM1P5KsSYswE987DPoFnpmkFpq8g1ae0eYbQfXy55ieaacC4QWyJPj3daU2kMfBQw7MXnnk0H/WDxouMOIHnd8MlQxpEMqAihj7KpuONH+MUhuj9HEQo4df6bSaIuQ0b4QIDAQAB" +} diff --git a/packages/extension/package.json b/packages/extension/package.json new file mode 100644 index 0000000000000..e9147f9660ca7 --- /dev/null +++ b/packages/extension/package.json @@ -0,0 +1,30 @@ +{ + "name": "@playwright/extension", + "version": "0.1.0", + "description": "Playwright Browser Extension", + "private": true, + "repository": { + "type": "git", + "url": "git+https://github.com/microsoft/playwright.git" + }, + "homepage": "https://playwright.dev", + "engines": { + "node": ">=18" + }, + "author": { + "name": "Microsoft Corporation" + }, + "license": "Apache-2.0", + "scripts": { + "build": "tsc --project . && tsc --project tsconfig.ui.json && vite build && vite build --config vite.sw.config.mts", + "watch": "tsc --watch --project . & tsc --watch --project tsconfig.ui.json & vite build --watch & vite build --watch --config vite.sw.config.mts", + "test": "playwright test", + "lint": "tsc --project .", + "clean": "rm -rf dist test-results" + }, + "devDependencies": { + "@types/chrome": "^0.0.315", + "minimist": "^1.2.5", + "vite-plugin-static-copy": "^3.1.1" + } +} diff --git a/packages/extension/src/background.ts b/packages/extension/src/background.ts new file mode 100644 index 0000000000000..901734b9e15ce --- /dev/null +++ b/packages/extension/src/background.ts @@ -0,0 +1,239 @@ +/** + * Copyright (c) Microsoft Corporation. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { RelayConnection, debugLog } from './relayConnection'; + +type PageMessage = { + type: 'connectToMCPRelay'; + mcpRelayUrl: string; + protocolVersion: number; +} | { + type: 'getTabs'; +} | { + type: 'connectToTab'; + tabId?: number; + windowId?: number; + mcpRelayUrl: string; +} | { + type: 'getConnectionStatus'; +} | { + type: 'disconnect'; +}; + +class TabShareExtension { + private _activeConnection: RelayConnection | undefined; + private _connectedTabIds: Set = new Set(); + private _groupId: number | null = null; + private _pendingTabSelection = new Map(); + + constructor() { + chrome.tabs.onRemoved.addListener(this._onTabRemoved.bind(this)); + chrome.tabs.onUpdated.addListener(this._onTabUpdated.bind(this)); + chrome.runtime.onMessage.addListener(this._onMessage.bind(this)); + chrome.action.onClicked.addListener(this._onActionClicked.bind(this)); + } + + // Promise-based message handling is not supported in Chrome: https://issues.chromium.org/issues/40753031 + private _onMessage(message: PageMessage, sender: chrome.runtime.MessageSender, sendResponse: (response: any) => void) { + switch (message.type) { + case 'connectToMCPRelay': + this._connectToRelay(sender.tab!.id!, message.mcpRelayUrl, message.protocolVersion).then( + () => sendResponse({ success: true }), + (error: any) => sendResponse({ success: false, error: error.message })); + return true; + case 'getTabs': + this._getTabs().then( + tabs => sendResponse({ success: true, tabs, currentTabId: sender.tab?.id }), + (error: any) => sendResponse({ success: false, error: error.message })); + return true; + case 'connectToTab': + const tabId = message.tabId || sender.tab?.id!; + const windowId = message.windowId || sender.tab?.windowId!; + this._connectTab(sender.tab!.id!, tabId, windowId, message.mcpRelayUrl!).then( + () => sendResponse({ success: true }), + (error: any) => sendResponse({ success: false, error: error.message })); + return true; // Return true to indicate that the response will be sent asynchronously + case 'getConnectionStatus': + sendResponse({ + connectedTabIds: [...this._connectedTabIds] + }); + return false; + case 'disconnect': + this._disconnect().then( + () => sendResponse({ success: true }), + (error: any) => sendResponse({ success: false, error: error.message })); + return true; + } + return false; + } + + private async _connectToRelay(selectorTabId: number, mcpRelayUrl: string, protocolVersion: number): Promise { + try { + debugLog(`Connecting to relay at ${mcpRelayUrl} (protocol v${protocolVersion})`); + const socket = new WebSocket(mcpRelayUrl); + await new Promise((resolve, reject) => { + socket.onopen = () => resolve(); + socket.onerror = () => reject(new Error('WebSocket error')); + setTimeout(() => reject(new Error('Connection timeout')), 5000); + }); + + const connection = new RelayConnection(socket, protocolVersion); + connection.onclose = () => { + debugLog('Pending connection closed'); + const existed = this._pendingTabSelection.delete(selectorTabId); + if (existed) { + chrome.tabs.sendMessage(selectorTabId, { type: 'pendingConnectionClosed' }).catch(() => {}); + chrome.tabs.ungroup(selectorTabId).catch(() => {}); + } + }; + this._pendingTabSelection.set(selectorTabId, connection); + await this._addTabToGroup(selectorTabId); + debugLog(`Connected to MCP relay`); + } catch (error: any) { + const message = `Failed to connect to MCP relay: ${error.message}`; + debugLog(message); + throw new Error(message); + } + } + + private async _connectTab(selectorTabId: number, tabId: number, windowId: number, mcpRelayUrl: string): Promise { + try { + debugLog(`Connecting tab ${tabId} to relay at ${mcpRelayUrl}`); + try { + this._activeConnection?.close('Another connection is requested'); + } catch (error: any) { + debugLog(`Error closing active connection:`, error); + } + await Promise.all([...this._connectedTabIds].map(id => this._updateBadge(id, { text: '' }))); + this._connectedTabIds.clear(); + + this._activeConnection = this._pendingTabSelection.get(selectorTabId); + if (!this._activeConnection) + throw new Error('Pending client connection closed'); + this._pendingTabSelection.delete(selectorTabId); + + this._activeConnection.setSelectedTab(tabId); + this._activeConnection.onclose = () => { + debugLog('MCP connection closed'); + this._activeConnection = undefined; + const allTabIds = [...this._connectedTabIds]; + this._connectedTabIds.clear(); + allTabIds.map(id => this._updateBadge(id, { text: '' })); + chrome.tabs.ungroup(allTabIds).catch(() => {}); + }; + this._activeConnection.ontabattached = (newTabId: number) => { + this._connectedTabIds.add(newTabId); + void this._updateBadge(newTabId, { text: '✓', color: '#4CAF50', title: 'Connected to Playwright client' }); + void this._addTabToGroup(newTabId); + }; + this._activeConnection.ontabdetached = (removedTabId: number) => { + this._connectedTabIds.delete(removedTabId); + void this._updateBadge(removedTabId, { text: '' }); + chrome.tabs.ungroup(removedTabId).catch(() => {}); + }; + + await Promise.all([ + chrome.tabs.update(tabId, { active: true }), + chrome.windows.update(windowId, { focused: true }), + ]); + debugLog(`Connected to Playwright client`); + } catch (error: any) { + this._connectedTabIds.clear(); + debugLog(`Failed to connect tab ${tabId}:`, error.message); + throw error; + } + } + + private async _updateBadge(tabId: number, { text, color, title }: { text: string; color?: string, title?: string }): Promise { + try { + await chrome.action.setBadgeText({ tabId, text }); + await chrome.action.setTitle({ tabId, title: title || '' }); + if (color) + await chrome.action.setBadgeBackgroundColor({ tabId, color }); + } catch (error: any) { + // Ignore errors as the tab may be closed already. + } + } + + private async _onTabRemoved(tabId: number): Promise { + const pendingConnection = this._pendingTabSelection.get(tabId); + if (pendingConnection) { + this._pendingTabSelection.delete(tabId); + pendingConnection.close('Browser tab closed'); + return; + } + // Tab removal is handled by RelayConnection (ontabdetached / onclose). + // No action needed here — the relay detects it via chrome.tabs.onRemoved + // and chrome.debugger.onDetach listeners. + } + + private _onTabUpdated(tabId: number, changeInfo: chrome.tabs.TabChangeInfo, tab: chrome.tabs.Tab) { + if (this._connectedTabIds.has(tabId)) + void this._updateBadge(tabId, { text: '✓', color: '#4CAF50', title: 'Connected to MCP client' }); + + if (!this._activeConnection || changeInfo.groupId === undefined) + return; + // Ignore the extension's own UI tabs (connect/status pages) — those get added + // to the group for visual grouping, not because they should be controlled. + if (tab.url?.startsWith(chrome.runtime.getURL(''))) + return; + const inOurGroup = this._groupId !== null && changeInfo.groupId === this._groupId; + const isConnected = this._connectedTabIds.has(tabId); + if (inOurGroup && !isConnected) + void this._activeConnection.attachTab(tabId); + else if (!inOurGroup && isConnected) + void this._activeConnection.detachTab(tabId); + } + + private async _getTabs(): Promise { + const tabs = await chrome.tabs.query({}); + return tabs.filter(tab => tab.url && !['chrome:', 'edge:', 'devtools:'].some(scheme => tab.url!.startsWith(scheme))); + } + + private async _addTabToGroup(tabId: number): Promise { + try { + if (this._groupId !== null) { + try { + await chrome.tabs.group({ groupId: this._groupId, tabIds: [tabId] }); + await chrome.tabGroups.update(this._groupId, { color: 'green', title: 'Playwright' }); + return; + } catch { + this._groupId = null; + } + } + this._groupId = await chrome.tabs.group({ tabIds: [tabId] }); + await chrome.tabGroups.update(this._groupId, { color: 'green', title: 'Playwright' }); + } catch (error: any) { + debugLog('Error adding tab to group:', error); + } + } + + private async _onActionClicked(): Promise { + await chrome.tabs.create({ + url: chrome.runtime.getURL('status.html'), + active: true + }); + } + + private async _disconnect(): Promise { + this._activeConnection?.close('User disconnected'); + this._activeConnection = undefined; + await Promise.all([...this._connectedTabIds].map(id => this._updateBadge(id, { text: '' }))); + this._connectedTabIds.clear(); + } +} + +new TabShareExtension(); diff --git a/packages/extension/src/relayConnection.ts b/packages/extension/src/relayConnection.ts new file mode 100644 index 0000000000000..1b1df33df27ca --- /dev/null +++ b/packages/extension/src/relayConnection.ts @@ -0,0 +1,337 @@ +/** + * Copyright (c) Microsoft Corporation. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +export function debugLog(...args: unknown[]): void { + const enabled = true; + if (enabled) { + // eslint-disable-next-line no-console + console.log('[Extension]', ...args); + } +} + +type ProtocolCommand = { + id: number; + method: string; + params?: any; +}; + +type ProtocolResponse = { + id?: number; + method?: string; + params?: any; + result?: any; + error?: string; +}; + +// Allow-listed chrome.* commands the relay may invoke. The handler resolves +// the method reflectively and spreads positional params. +const ALLOWED_CHROME_COMMANDS = new Set([ + 'chrome.debugger.attach', + 'chrome.debugger.detach', + 'chrome.debugger.sendCommand', + 'chrome.tabs.create', + 'chrome.tabs.remove', +]); + +// chrome.* events the extension forwards to the relay (positional params). +type ChromeEvent = { + api: 'chrome.debugger' | 'chrome.tabs'; + event: 'onEvent' | 'onDetach' | 'onCreated' | 'onRemoved'; + fullMethod: string; +}; + +const CHROME_EVENTS: ChromeEvent[] = [ + { api: 'chrome.debugger', event: 'onEvent', fullMethod: 'chrome.debugger.onEvent' }, + { api: 'chrome.debugger', event: 'onDetach', fullMethod: 'chrome.debugger.onDetach' }, + { api: 'chrome.tabs', event: 'onCreated', fullMethod: 'chrome.tabs.onCreated' }, + { api: 'chrome.tabs', event: 'onRemoved', fullMethod: 'chrome.tabs.onRemoved' }, +]; + +export class RelayConnection { + private _ws: WebSocket; + private _protocolVersion: number; + // Tabs whose debugger we have explicitly attached for this connection. + private _attachedTabs = new Set(); + // Once we've attached at least one tab, detaching the last one closes the connection. + private _hasEverAttached = false; + private _eventListeners: Array<{ remove: () => void }> = []; + private _selectedTabPromise: Promise; + private _selectedTabResolve!: (tabId: number) => void; + private _closed = false; + + onclose?: () => void; + ontabattached?: (tabId: number) => void; + ontabdetached?: (tabId: number) => void; + + constructor(ws: WebSocket, protocolVersion: number) { + this._ws = ws; + this._protocolVersion = protocolVersion; + this._selectedTabPromise = new Promise(resolve => this._selectedTabResolve = resolve); + this._installEventForwarders(); + this._ws.onmessage = this._onMessage.bind(this); + this._ws.onclose = () => this._onClose(); + } + + // Resolves the pending extension.selectTab call from cdpRelay. + setSelectedTab(tabId: number): void { + this._selectedTabResolve(tabId); + } + + close(message: string): void { + this._ws.close(1000, message); + // ws.onclose is called asynchronously, so we call it here to avoid forwarding + // CDP events to the closed connection. + this._onClose(); + } + + // Simulates a "new tab opened" event for a tab the user added to the group. + // The relay reacts by issuing chrome.debugger.attach, which flows through + // the normal command path and fires ontabattached. + async attachTab(tabId: number): Promise { + if (this._closed || this._protocolVersion !== 2) + return; + if (this._attachedTabs.has(tabId)) + return; + try { + const tab = await chrome.tabs.get(tabId); + this._sendMessage({ method: 'chrome.tabs.onCreated', params: [tab] }); + } catch (error: any) { + debugLog('Error requesting attach for tab:', error); + } + } + + // Simulates a "tab closed" event for a tab the user removed from the group. + // chrome.debugger.detach does not fire onDetach for the caller, so we do the + // bookkeeping and notify the relay ourselves. + async detachTab(tabId: number): Promise { + if (this._closed) + return; + if (!this._attachedTabs.has(tabId)) + return; + try { + await chrome.debugger.detach({ tabId }); + } catch (error: any) { + debugLog('Error detaching tab:', error); + } + this._attachedTabs.delete(tabId); + this.ontabdetached?.(tabId); + if (this._protocolVersion === 2) { + this._sendMessage({ + method: 'chrome.debugger.onDetach', + params: [{ tabId }, 'target_closed'], + }); + } + this._checkLastTabDetached(); + } + + private _installEventForwarders(): void { + for (const { fullMethod } of CHROME_EVENTS) { + const target = this._resolveChromeMember(fullMethod); + const listener = (...args: any[]) => this._onChromeEvent(fullMethod, args); + target.obj[target.name].addListener(listener); + this._eventListeners.push({ + remove: () => target.obj[target.name].removeListener(listener), + }); + } + } + + private _onClose() { + if (this._closed) + return; + this._closed = true; + for (const l of this._eventListeners) + l.remove(); + this._eventListeners = []; + for (const tabId of this._attachedTabs) + chrome.debugger.detach({ tabId }).catch(() => {}); + this._attachedTabs.clear(); + this.onclose?.(); + } + + private _checkLastTabDetached(): void { + if (this._hasEverAttached && this._attachedTabs.size === 0) + this.close('All controlled tabs detached'); + } + + // Single dispatcher for every forwarded chrome.* event. + private _onChromeEvent(fullMethod: string, args: any[]): void { + // Filter events to those concerning tabs we've explicitly attached. + const tabId = this._tabIdForEventArgs(fullMethod, args); + if (tabId === undefined || !this._attachedTabs.has(tabId)) + return; + + // v1 only forwards CDP events from the single attached tab. + if (this._protocolVersion === 1) { + if (fullMethod === 'chrome.debugger.onEvent') { + const [source, method, params] = args as [chrome.debugger.DebuggerSession, string, any]; + this._sendMessage({ + method: 'forwardCDPEvent', + params: { + sessionId: source.sessionId, + method, + params, + }, + }); + } + // Other events have no v1 equivalent — drop them. Detach bookkeeping happens below. + } else { + this._sendMessage({ method: fullMethod, params: args }); + } + + // Detach bookkeeping (single source of truth: chrome.debugger.onDetach). + if (fullMethod === 'chrome.debugger.onDetach') { + this._attachedTabs.delete(tabId); + this.ontabdetached?.(tabId); + this._checkLastTabDetached(); + } + } + + // Returns the tabId an event refers to, for filtering by _attachedTabs. + private _tabIdForEventArgs(fullMethod: string, args: any[]): number | undefined { + switch (fullMethod) { + case 'chrome.debugger.onEvent': + case 'chrome.debugger.onDetach': + return (args[0] as chrome.debugger.Debuggee | undefined)?.tabId; + case 'chrome.tabs.onCreated': { + const tab = args[0] as chrome.tabs.Tab; + // Forward only popups opened by an attached tab; report the opener so cdpRelay + // can filter / decide. We use the openerTabId for the attached-tab check. + return tab.openerTabId; + } + case 'chrome.tabs.onRemoved': + return args[0] as number; + } + return undefined; + } + + private _onMessage(event: MessageEvent): void { + this._onMessageAsync(event).catch(e => debugLog('Error handling message:', e)); + } + + private async _onMessageAsync(event: MessageEvent): Promise { + let message: ProtocolCommand; + try { + message = JSON.parse(event.data); + } catch (error: any) { + debugLog('Error parsing message:', error); + this._sendError(-32700, `Error parsing message: ${error.message}`); + return; + } + + debugLog('Received message:', message); + + const response: ProtocolResponse = { + id: message.id, + }; + try { + response.result = await this._handleCommand(message); + } catch (error: any) { + debugLog('Error handling command:', error); + response.error = error.message; + } + debugLog('Sending response:', response); + this._sendMessage(response); + } + + private async _handleCommand(message: ProtocolCommand): Promise { + // Playwright-specific tab picker. + if (message.method === 'extension.selectTab') { + const tabId = await this._selectedTabPromise; + return { tabId }; + } + + // Reflective chrome.* dispatch: spread positional params into the API. + if (ALLOWED_CHROME_COMMANDS.has(message.method)) { + const args = (message.params ?? []) as any[]; + const result = await this._invokeChromeMethod(message.method, args); + this._postChromeCommand(message.method, args); + return result ?? {}; + } + + // ─── Protocol v1 (legacy single-tab) ───────────────────────────────────── + if (message.method === 'attachToTab') { + const tabId = await this._selectedTabPromise; + const debuggee: chrome.debugger.Debuggee = { tabId }; + await chrome.debugger.attach(debuggee, '1.3'); + this._attachedTabs.add(tabId); + this._hasEverAttached = true; + this.ontabattached?.(tabId); + const result: any = await chrome.debugger.sendCommand(debuggee, 'Target.getTargetInfo'); + return { targetInfo: result?.targetInfo }; + } + if (message.method === 'forwardCDPCommand') { + const { sessionId, method, params } = message.params; + if (method === 'Target.createTarget') + throw new Error('Tab creation is not supported yet. Update Playwright MCP or CLI to the latest version.'); + const tabId = [...this._attachedTabs][0]; + if (tabId === undefined) + throw new Error('No tab is connected'); + const debuggerSession: chrome.debugger.DebuggerSession = { tabId, sessionId }; + return await chrome.debugger.sendCommand(debuggerSession, method, params); + } + + throw new Error(`Unknown method: ${message.method}`); + } + + // Reflectively resolves chrome.. and invokes it with positional args. + private async _invokeChromeMethod(fullMethod: string, args: any[]): Promise { + const { obj, name } = this._resolveChromeMember(fullMethod); + const fn = obj[name] as (...a: any[]) => any; + if (typeof fn !== 'function') + throw new Error(`Not a function: ${fullMethod}`); + return await fn.apply(obj, args); + } + + // Bookkeeping that must run after a successful chrome.* command. + private _postChromeCommand(fullMethod: string, args: any[]): void { + if (fullMethod === 'chrome.debugger.attach') { + const target = args[0] as chrome.debugger.Debuggee; + if (target.tabId !== undefined) { + this._attachedTabs.add(target.tabId); + this._hasEverAttached = true; + this.ontabattached?.(target.tabId); + } + } + // Detach is handled via the chrome.debugger.onDetach event listener. + } + + private _resolveChromeMember(fullMethod: string): { obj: any; name: string } { + const parts = fullMethod.split('.'); + if (parts[0] !== 'chrome' || parts.length < 3) + throw new Error(`Invalid chrome method: ${fullMethod}`); + let obj: any = chrome; + for (let i = 1; i < parts.length - 1; i++) { + obj = obj?.[parts[i]]; + if (obj === undefined) + throw new Error(`Unknown chrome path: ${parts.slice(0, i + 1).join('.')}, calling ${fullMethod}`); + } + return { obj, name: parts[parts.length - 1] }; + } + + private _sendError(code: number, message: string): void { + this._sendMessage({ + error: { + code, + message, + }, + }); + } + + private _sendMessage(message: any): void { + if (this._ws.readyState === WebSocket.OPEN) + this._ws.send(JSON.stringify(message)); + } +} diff --git a/packages/extension/src/ui/authToken.css b/packages/extension/src/ui/authToken.css new file mode 100644 index 0000000000000..adfa30cd2bc60 --- /dev/null +++ b/packages/extension/src/ui/authToken.css @@ -0,0 +1,142 @@ +/* + Copyright (c) Microsoft Corporation. + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +*/ + +.auth-token-section { + margin: 16px 0; + padding: 16px; + background-color: #f6f8fa; + border-radius: 6px; +} + +.auth-token-description { + font-size: 12px; + color: #656d76; + margin-bottom: 12px; +} + +.auth-token-container { + display: flex; + align-items: center; + gap: 8px; + background-color: #ffffff; + padding: 8px; +} + +.auth-token-code { + font-family: 'SFMono-Regular', Consolas, 'Liberation Mono', Menlo, monospace; + font-size: 12px; + color: #1f2328; + border: none; + flex: 1; + padding: 0; + word-break: break-all; +} + +.auth-token-refresh { + flex: none; + height: 24px; + width: 24px; + border: none; + outline: none; + color: var(--color-fg-muted); + background: transparent; + padding: 4px; + cursor: pointer; + display: inline-flex; + align-items: center; + justify-content: center; + border-radius: 4px; +} + +.auth-token-refresh svg { + margin: 0; +} + +.auth-token-refresh:not(:disabled):hover { + background-color: var(--color-btn-selected-bg); +} + +.auth-token-example-section { + margin-top: 16px; +} + +.auth-token-example-toggle { + display: flex; + align-items: center; + gap: 8px; + background: none; + border: none; + padding: 8px 0; + font-size: 12px; + color: #656d76; + cursor: pointer; + outline: none; + text-align: left; + width: 100%; +} + +.auth-token-example-toggle:hover { + color: #1f2328; +} + +.auth-token-chevron { + display: inline-flex; + align-items: center; + justify-content: center; + transform: rotate(-90deg); + flex-shrink: 0; +} + +.auth-token-chevron.expanded { + transform: rotate(0deg); +} + +.auth-token-chevron svg { + width: 12px; + height: 12px; +} + +.auth-token-chevron .octicon { + margin: 0px; +} + +.auth-token-example-content { + margin-top: 12px; + padding: 12px 0; +} + +.auth-token-example-description { + font-size: 12px; + color: #656d76; + margin-bottom: 12px; +} + +.auth-token-example-config { + display: flex; + align-items: flex-start; + gap: 8px; + background-color: #ffffff; + padding: 12px; +} + +.auth-token-example-code { + font-family: 'SFMono-Regular', Consolas, 'Liberation Mono', Menlo, monospace; + font-size: 11px; + color: #1f2328; + white-space: pre; + flex: 1; + line-height: 1.4; +} diff --git a/packages/extension/src/ui/authToken.tsx b/packages/extension/src/ui/authToken.tsx new file mode 100644 index 0000000000000..27cdbe1e174cd --- /dev/null +++ b/packages/extension/src/ui/authToken.tsx @@ -0,0 +1,72 @@ +/** + * Copyright (c) Microsoft Corporation. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import React, { useCallback, useState } from 'react'; +import { CopyToClipboard } from './copyToClipboard'; +import * as icons from './icons'; +import './authToken.css'; + +export const AuthTokenSection: React.FC<{}> = ({}) => { + const [authToken, setAuthToken] = useState(getOrCreateAuthToken); + + const onRegenerateToken = useCallback(() => { + const newToken = generateAuthToken(); + localStorage.setItem('auth-token', newToken); + setAuthToken(newToken); + }, []); + + return ( +
+
+ Set this environment variable to bypass the connection dialog: +
+
+ {authTokenCode(authToken)} + + +
+
+ ); +}; + +function authTokenCode(authToken: string) { + return `PLAYWRIGHT_MCP_EXTENSION_TOKEN=${authToken}`; +} + +function generateAuthToken(): string { + // Generate a cryptographically secure random token + const array = new Uint8Array(32); + crypto.getRandomValues(array); + // Convert to base64 and make it URL-safe + return btoa(String.fromCharCode.apply(null, Array.from(array))) + .replace(/[+/=]/g, match => { + switch (match) { + case '+': return '-'; + case '/': return '_'; + case '=': return ''; + default: return match; + } + }); +} + +export const getOrCreateAuthToken = (): string => { + let token = localStorage.getItem('auth-token'); + if (!token) { + token = generateAuthToken(); + localStorage.setItem('auth-token', token); + } + return token; +}; diff --git a/packages/extension/src/ui/colors.css b/packages/extension/src/ui/colors.css new file mode 100644 index 0000000000000..42b254ffa3805 --- /dev/null +++ b/packages/extension/src/ui/colors.css @@ -0,0 +1,891 @@ +/* The MIT License (MIT) + +Copyright (c) 2021 GitHub Inc. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. */ + +:root { + --color-canvas-default-transparent: rgba(255,255,255,0); + --color-marketing-icon-primary: #218bff; + --color-marketing-icon-secondary: #54aeff; + --color-diff-blob-addition-num-text: #24292f; + --color-diff-blob-addition-fg: #24292f; + --color-diff-blob-addition-num-bg: #CCFFD8; + --color-diff-blob-addition-line-bg: #E6FFEC; + --color-diff-blob-addition-word-bg: #ABF2BC; + --color-diff-blob-deletion-num-text: #24292f; + --color-diff-blob-deletion-fg: #24292f; + --color-diff-blob-deletion-num-bg: #FFD7D5; + --color-diff-blob-deletion-line-bg: #FFEBE9; + --color-diff-blob-deletion-word-bg: rgba(255,129,130,0.4); + --color-diff-blob-hunk-num-bg: rgba(84,174,255,0.4); + --color-diff-blob-expander-icon: #57606a; + --color-diff-blob-selected-line-highlight-mix-blend-mode: multiply; + --color-diffstat-deletion-border: rgba(27,31,36,0.15); + --color-diffstat-addition-border: rgba(27,31,36,0.15); + --color-diffstat-addition-bg: #2da44e; + --color-search-keyword-hl: #fff8c5; + --color-prettylights-syntax-comment: #6e7781; + --color-prettylights-syntax-constant: #0550ae; + --color-prettylights-syntax-entity: #8250df; + --color-prettylights-syntax-storage-modifier-import: #24292f; + --color-prettylights-syntax-entity-tag: #116329; + --color-prettylights-syntax-keyword: #cf222e; + --color-prettylights-syntax-string: #0a3069; + --color-prettylights-syntax-variable: #953800; + --color-prettylights-syntax-brackethighlighter-unmatched: #82071e; + --color-prettylights-syntax-invalid-illegal-text: #f6f8fa; + --color-prettylights-syntax-invalid-illegal-bg: #82071e; + --color-prettylights-syntax-carriage-return-text: #f6f8fa; + --color-prettylights-syntax-carriage-return-bg: #cf222e; + --color-prettylights-syntax-string-regexp: #116329; + --color-prettylights-syntax-markup-list: #3b2300; + --color-prettylights-syntax-markup-heading: #0550ae; + --color-prettylights-syntax-markup-italic: #24292f; + --color-prettylights-syntax-markup-bold: #24292f; + --color-prettylights-syntax-markup-deleted-text: #82071e; + --color-prettylights-syntax-markup-deleted-bg: #FFEBE9; + --color-prettylights-syntax-markup-inserted-text: #116329; + --color-prettylights-syntax-markup-inserted-bg: #dafbe1; + --color-prettylights-syntax-markup-changed-text: #953800; + --color-prettylights-syntax-markup-changed-bg: #ffd8b5; + --color-prettylights-syntax-markup-ignored-text: #eaeef2; + --color-prettylights-syntax-markup-ignored-bg: #0550ae; + --color-prettylights-syntax-meta-diff-range: #8250df; + --color-prettylights-syntax-brackethighlighter-angle: #57606a; + --color-prettylights-syntax-sublimelinter-gutter-mark: #8c959f; + --color-prettylights-syntax-constant-other-reference-link: #0a3069; + --color-codemirror-text: #24292f; + --color-codemirror-bg: #ffffff; + --color-codemirror-gutters-bg: #ffffff; + --color-codemirror-guttermarker-text: #ffffff; + --color-codemirror-guttermarker-subtle-text: #6e7781; + --color-codemirror-linenumber-text: #57606a; + --color-codemirror-cursor: #24292f; + --color-codemirror-selection-bg: rgba(84,174,255,0.4); + --color-codemirror-activeline-bg: rgba(234,238,242,0.5); + --color-codemirror-matchingbracket-text: #24292f; + --color-codemirror-lines-bg: #ffffff; + --color-codemirror-syntax-comment: #24292f; + --color-codemirror-syntax-constant: #0550ae; + --color-codemirror-syntax-entity: #8250df; + --color-codemirror-syntax-keyword: #cf222e; + --color-codemirror-syntax-storage: #cf222e; + --color-codemirror-syntax-string: #0a3069; + --color-codemirror-syntax-support: #0550ae; + --color-codemirror-syntax-variable: #953800; + --color-checks-bg: #24292f; + --color-checks-run-border-width: 0px; + --color-checks-container-border-width: 0px; + --color-checks-text-primary: #f6f8fa; + --color-checks-text-secondary: #8c959f; + --color-checks-text-link: #54aeff; + --color-checks-btn-icon: #afb8c1; + --color-checks-btn-hover-icon: #f6f8fa; + --color-checks-btn-hover-bg: rgba(255,255,255,0.125); + --color-checks-input-text: #eaeef2; + --color-checks-input-placeholder-text: #8c959f; + --color-checks-input-focus-text: #8c959f; + --color-checks-input-bg: #32383f; + --color-checks-input-shadow: none; + --color-checks-donut-error: #fa4549; + --color-checks-donut-pending: #bf8700; + --color-checks-donut-success: #2da44e; + --color-checks-donut-neutral: #afb8c1; + --color-checks-dropdown-text: #afb8c1; + --color-checks-dropdown-bg: #32383f; + --color-checks-dropdown-border: #424a53; + --color-checks-dropdown-shadow: rgba(27,31,36,0.3); + --color-checks-dropdown-hover-text: #f6f8fa; + --color-checks-dropdown-hover-bg: #424a53; + --color-checks-dropdown-btn-hover-text: #f6f8fa; + --color-checks-dropdown-btn-hover-bg: #32383f; + --color-checks-scrollbar-thumb-bg: #57606a; + --color-checks-header-label-text: #d0d7de; + --color-checks-header-label-open-text: #f6f8fa; + --color-checks-header-border: #32383f; + --color-checks-header-icon: #8c959f; + --color-checks-line-text: #d0d7de; + --color-checks-line-num-text: rgba(140,149,159,0.75); + --color-checks-line-timestamp-text: #8c959f; + --color-checks-line-hover-bg: #32383f; + --color-checks-line-selected-bg: rgba(33,139,255,0.15); + --color-checks-line-selected-num-text: #54aeff; + --color-checks-line-dt-fm-text: #24292f; + --color-checks-line-dt-fm-bg: #9a6700; + --color-checks-gate-bg: rgba(125,78,0,0.15); + --color-checks-gate-text: #d0d7de; + --color-checks-gate-waiting-text: #afb8c1; + --color-checks-step-header-open-bg: #32383f; + --color-checks-step-error-text: #ff8182; + --color-checks-step-warning-text: #d4a72c; + --color-checks-logline-text: #8c959f; + --color-checks-logline-num-text: rgba(140,149,159,0.75); + --color-checks-logline-debug-text: #c297ff; + --color-checks-logline-error-text: #d0d7de; + --color-checks-logline-error-num-text: #ff8182; + --color-checks-logline-error-bg: rgba(164,14,38,0.15); + --color-checks-logline-warning-text: #d0d7de; + --color-checks-logline-warning-num-text: #d4a72c; + --color-checks-logline-warning-bg: rgba(125,78,0,0.15); + --color-checks-logline-command-text: #54aeff; + --color-checks-logline-section-text: #4ac26b; + --color-checks-ansi-black: #24292f; + --color-checks-ansi-black-bright: #32383f; + --color-checks-ansi-white: #d0d7de; + --color-checks-ansi-white-bright: #d0d7de; + --color-checks-ansi-gray: #8c959f; + --color-checks-ansi-red: #ff8182; + --color-checks-ansi-red-bright: #ffaba8; + --color-checks-ansi-green: #4ac26b; + --color-checks-ansi-green-bright: #6fdd8b; + --color-checks-ansi-yellow: #d4a72c; + --color-checks-ansi-yellow-bright: #eac54f; + --color-checks-ansi-blue: #54aeff; + --color-checks-ansi-blue-bright: #80ccff; + --color-checks-ansi-magenta: #c297ff; + --color-checks-ansi-magenta-bright: #d8b9ff; + --color-checks-ansi-cyan: #76e3ea; + --color-checks-ansi-cyan-bright: #b3f0ff; + --color-project-header-bg: #24292f; + --color-project-sidebar-bg: #ffffff; + --color-project-gradient-in: #ffffff; + --color-project-gradient-out: rgba(255,255,255,0); + --color-mktg-success: rgba(36,146,67,1); + --color-mktg-info: rgba(19,119,234,1); + --color-mktg-bg-shade-gradient-top: rgba(27,31,36,0.065); + --color-mktg-bg-shade-gradient-bottom: rgba(27,31,36,0); + --color-mktg-btn-bg-top: hsla(228,82%,66%,1); + --color-mktg-btn-bg-bottom: #4969ed; + --color-mktg-btn-bg-overlay-top: hsla(228,74%,59%,1); + --color-mktg-btn-bg-overlay-bottom: #3355e0; + --color-mktg-btn-text: #ffffff; + --color-mktg-btn-primary-bg-top: hsla(137,56%,46%,1); + --color-mktg-btn-primary-bg-bottom: #2ea44f; + --color-mktg-btn-primary-bg-overlay-top: hsla(134,60%,38%,1); + --color-mktg-btn-primary-bg-overlay-bottom: #22863a; + --color-mktg-btn-primary-text: #ffffff; + --color-mktg-btn-enterprise-bg-top: hsla(249,100%,72%,1); + --color-mktg-btn-enterprise-bg-bottom: #6f57ff; + --color-mktg-btn-enterprise-bg-overlay-top: hsla(248,65%,63%,1); + --color-mktg-btn-enterprise-bg-overlay-bottom: #614eda; + --color-mktg-btn-enterprise-text: #ffffff; + --color-mktg-btn-outline-text: #4969ed; + --color-mktg-btn-outline-border: rgba(73,105,237,0.3); + --color-mktg-btn-outline-hover-text: #3355e0; + --color-mktg-btn-outline-hover-border: rgba(51,85,224,0.5); + --color-mktg-btn-outline-focus-border: #4969ed; + --color-mktg-btn-outline-focus-border-inset: rgba(73,105,237,0.5); + --color-mktg-btn-dark-text: #ffffff; + --color-mktg-btn-dark-border: rgba(255,255,255,0.3); + --color-mktg-btn-dark-hover-text: #ffffff; + --color-mktg-btn-dark-hover-border: rgba(255,255,255,0.5); + --color-mktg-btn-dark-focus-border: #ffffff; + --color-mktg-btn-dark-focus-border-inset: rgba(255,255,255,0.5); + --color-avatar-bg: #ffffff; + --color-avatar-border: rgba(27,31,36,0.15); + --color-avatar-stack-fade: #afb8c1; + --color-avatar-stack-fade-more: #d0d7de; + --color-avatar-child-shadow: -2px -2px 0 rgba(255,255,255,0.8); + --color-topic-tag-border: rgba(0,0,0,0); + --color-select-menu-backdrop-border: rgba(0,0,0,0); + --color-select-menu-tap-highlight: rgba(175,184,193,0.5); + --color-select-menu-tap-focus-bg: #b6e3ff; + --color-overlay-shadow: 0 1px 3px rgba(27,31,36,0.12), 0 8px 24px rgba(66,74,83,0.12); + --color-header-text: rgba(255,255,255,0.7); + --color-header-bg: #24292f; + --color-header-logo: #ffffff; + --color-header-search-bg: #24292f; + --color-header-search-border: #57606a; + --color-sidenav-selected-bg: #ffffff; + --color-menu-bg-active: rgba(0,0,0,0); + --color-control-transparent-bg-hover: #818b981a; + --color-input-disabled-bg: rgba(175,184,193,0.2); + --color-timeline-badge-bg: #eaeef2; + --color-ansi-black: #24292f; + --color-ansi-black-bright: #57606a; + --color-ansi-white: #6e7781; + --color-ansi-white-bright: #8c959f; + --color-ansi-gray: #6e7781; + --color-ansi-red: #cf222e; + --color-ansi-red-bright: #a40e26; + --color-ansi-green: #116329; + --color-ansi-green-bright: #1a7f37; + --color-ansi-yellow: #4d2d00; + --color-ansi-yellow-bright: #633c01; + --color-ansi-blue: #0969da; + --color-ansi-blue-bright: #218bff; + --color-ansi-magenta: #8250df; + --color-ansi-magenta-bright: #a475f9; + --color-ansi-cyan: #1b7c83; + --color-ansi-cyan-bright: #3192aa; + --color-btn-text: #24292f; + --color-btn-bg: #f6f8fa; + --color-btn-border: rgba(27,31,36,0.15); + --color-btn-shadow: 0 1px 0 rgba(27,31,36,0.04); + --color-btn-inset-shadow: inset 0 1px 0 rgba(255,255,255,0.25); + --color-btn-hover-bg: #f3f4f6; + --color-btn-hover-border: rgba(27,31,36,0.15); + --color-btn-active-bg: hsla(220,14%,93%,1); + --color-btn-active-border: rgba(27,31,36,0.15); + --color-btn-selected-bg: hsla(220,14%,94%,1); + --color-btn-focus-bg: #f6f8fa; + --color-btn-focus-border: rgba(27,31,36,0.15); + --color-btn-focus-shadow: 0 0 0 3px rgba(9,105,218,0.3); + --color-btn-shadow-active: inset 0 0.15em 0.3em rgba(27,31,36,0.15); + --color-btn-shadow-input-focus: 0 0 0 0.2em rgba(9,105,218,0.3); + --color-btn-counter-bg: rgba(27,31,36,0.08); + --color-btn-primary-text: #ffffff; + --color-btn-primary-bg: #2da44e; + --color-btn-primary-border: rgba(27,31,36,0.15); + --color-btn-primary-shadow: 0 1px 0 rgba(27,31,36,0.1); + --color-btn-primary-inset-shadow: inset 0 1px 0 rgba(255,255,255,0.03); + --color-btn-primary-hover-bg: #2c974b; + --color-btn-primary-hover-border: rgba(27,31,36,0.15); + --color-btn-primary-selected-bg: hsla(137,55%,36%,1); + --color-btn-primary-selected-shadow: inset 0 1px 0 rgba(0,45,17,0.2); + --color-btn-primary-disabled-text: rgba(255,255,255,0.8); + --color-btn-primary-disabled-bg: #94d3a2; + --color-btn-primary-disabled-border: rgba(27,31,36,0.15); + --color-btn-primary-focus-bg: #2da44e; + --color-btn-primary-focus-border: rgba(27,31,36,0.15); + --color-btn-primary-focus-shadow: 0 0 0 3px rgba(45,164,78,0.4); + --color-btn-primary-icon: rgba(255,255,255,0.8); + --color-btn-primary-counter-bg: rgba(255,255,255,0.2); + --color-btn-outline-text: #0969da; + --color-btn-outline-hover-text: #ffffff; + --color-btn-outline-hover-bg: #0969da; + --color-btn-outline-hover-border: rgba(27,31,36,0.15); + --color-btn-outline-hover-shadow: 0 1px 0 rgba(27,31,36,0.1); + --color-btn-outline-hover-inset-shadow: inset 0 1px 0 rgba(255,255,255,0.03); + --color-btn-outline-hover-counter-bg: rgba(255,255,255,0.2); + --color-btn-outline-selected-text: #ffffff; + --color-btn-outline-selected-bg: hsla(212,92%,42%,1); + --color-btn-outline-selected-border: rgba(27,31,36,0.15); + --color-btn-outline-selected-shadow: inset 0 1px 0 rgba(0,33,85,0.2); + --color-btn-outline-disabled-text: rgba(9,105,218,0.5); + --color-btn-outline-disabled-bg: #f6f8fa; + --color-btn-outline-disabled-counter-bg: rgba(9,105,218,0.05); + --color-btn-outline-focus-border: rgba(27,31,36,0.15); + --color-btn-outline-focus-shadow: 0 0 0 3px rgba(5,80,174,0.4); + --color-btn-outline-counter-bg: rgba(9,105,218,0.1); + --color-btn-danger-text: #cf222e; + --color-btn-danger-hover-text: #ffffff; + --color-btn-danger-hover-bg: #a40e26; + --color-btn-danger-hover-border: rgba(27,31,36,0.15); + --color-btn-danger-hover-shadow: 0 1px 0 rgba(27,31,36,0.1); + --color-btn-danger-hover-inset-shadow: inset 0 1px 0 rgba(255,255,255,0.03); + --color-btn-danger-hover-counter-bg: rgba(255,255,255,0.2); + --color-btn-danger-selected-text: #ffffff; + --color-btn-danger-selected-bg: hsla(356,72%,44%,1); + --color-btn-danger-selected-border: rgba(27,31,36,0.15); + --color-btn-danger-selected-shadow: inset 0 1px 0 rgba(76,0,20,0.2); + --color-btn-danger-disabled-text: rgba(207,34,46,0.5); + --color-btn-danger-disabled-bg: #f6f8fa; + --color-btn-danger-disabled-counter-bg: rgba(207,34,46,0.05); + --color-btn-danger-focus-border: rgba(27,31,36,0.15); + --color-btn-danger-focus-shadow: 0 0 0 3px rgba(164,14,38,0.4); + --color-btn-danger-counter-bg: rgba(207,34,46,0.1); + --color-btn-danger-icon: #cf222e; + --color-btn-danger-hover-icon: #ffffff; + --color-underlinenav-icon: #6e7781; + --color-underlinenav-border-hover: rgba(175,184,193,0.2); + --color-fg-default: #24292f; + --color-fg-muted: #57606a; + --color-fg-subtle: #6e7781; + --color-fg-on-emphasis: #ffffff; + --color-canvas-default: #ffffff; + --color-canvas-overlay: #ffffff; + --color-canvas-inset: #f6f8fa; + --color-canvas-subtle: #f6f8fa; + --color-border-default: #d0d7de; + --color-border-muted: hsla(210,18%,87%,1); + --color-border-subtle: rgba(27,31,36,0.15); + --color-shadow-small: 0 1px 0 rgba(27,31,36,0.04); + --color-shadow-medium: 0 3px 6px rgba(140,149,159,0.15); + --color-shadow-large: 0 8px 24px rgba(140,149,159,0.2); + --color-shadow-extra-large: 0 12px 28px rgba(140,149,159,0.3); + --color-neutral-emphasis-plus: #24292f; + --color-neutral-emphasis: #6e7781; + --color-neutral-muted: rgba(175,184,193,0.2); + --color-neutral-subtle: rgba(234,238,242,0.5); + --color-accent-fg: #0969da; + --color-accent-emphasis: #0969da; + --color-accent-muted: rgba(84,174,255,0.4); + --color-accent-subtle: #ddf4ff; + --color-success-fg: #1a7f37; + --color-success-emphasis: #2da44e; + --color-success-muted: rgba(74,194,107,0.4); + --color-success-subtle: #dafbe1; + --color-attention-fg: #9a6700; + --color-attention-emphasis: #bf8700; + --color-attention-muted: rgba(212,167,44,0.4); + --color-attention-subtle: #fff8c5; + --color-severe-fg: #bc4c00; + --color-severe-emphasis: #bc4c00; + --color-severe-muted: rgba(251,143,68,0.4); + --color-severe-subtle: #fff1e5; + --color-danger-fg: #cf222e; + --color-danger-emphasis: #cf222e; + --color-danger-muted: rgba(255,129,130,0.4); + --color-danger-subtle: #FFEBE9; + --color-done-fg: #8250df; + --color-done-emphasis: #8250df; + --color-done-muted: rgba(194,151,255,0.4); + --color-done-subtle: #fbefff; + --color-sponsors-fg: #bf3989; + --color-sponsors-emphasis: #bf3989; + --color-sponsors-muted: rgba(255,128,200,0.4); + --color-sponsors-subtle: #ffeff7; + --color-primer-canvas-backdrop: rgba(27,31,36,0.5); + --color-primer-canvas-sticky: rgba(255,255,255,0.95); + --color-primer-border-active: #FD8C73; + --color-primer-border-contrast: rgba(27,31,36,0.1); + --color-primer-shadow-highlight: inset 0 1px 0 rgba(255,255,255,0.25); + --color-primer-shadow-inset: inset 0 1px 0 rgba(208,215,222,0.2); + --color-primer-shadow-focus: 0 0 0 3px rgba(9,105,218,0.3); + --color-scale-black: #1b1f24; + --color-scale-white: #ffffff; + --color-scale-gray-0: #f6f8fa; + --color-scale-gray-1: #eaeef2; + --color-scale-gray-2: #d0d7de; + --color-scale-gray-3: #afb8c1; + --color-scale-gray-4: #8c959f; + --color-scale-gray-5: #6e7781; + --color-scale-gray-6: #57606a; + --color-scale-gray-7: #424a53; + --color-scale-gray-8: #32383f; + --color-scale-gray-9: #24292f; + --color-scale-blue-0: #ddf4ff; + --color-scale-blue-1: #b6e3ff; + --color-scale-blue-2: #80ccff; + --color-scale-blue-3: #54aeff; + --color-scale-blue-4: #218bff; + --color-scale-blue-5: #0969da; + --color-scale-blue-6: #0550ae; + --color-scale-blue-7: #033d8b; + --color-scale-blue-8: #0a3069; + --color-scale-blue-9: #002155; + --color-scale-green-0: #dafbe1; + --color-scale-green-1: #aceebb; + --color-scale-green-2: #6fdd8b; + --color-scale-green-3: #4ac26b; + --color-scale-green-4: #2da44e; + --color-scale-green-5: #1a7f37; + --color-scale-green-6: #116329; + --color-scale-green-7: #044f1e; + --color-scale-green-8: #003d16; + --color-scale-green-9: #002d11; + --color-scale-yellow-0: #fff8c5; + --color-scale-yellow-1: #fae17d; + --color-scale-yellow-2: #eac54f; + --color-scale-yellow-3: #d4a72c; + --color-scale-yellow-4: #bf8700; + --color-scale-yellow-5: #9a6700; + --color-scale-yellow-6: #7d4e00; + --color-scale-yellow-7: #633c01; + --color-scale-yellow-8: #4d2d00; + --color-scale-yellow-9: #3b2300; + --color-scale-orange-0: #fff1e5; + --color-scale-orange-1: #ffd8b5; + --color-scale-orange-2: #ffb77c; + --color-scale-orange-3: #fb8f44; + --color-scale-orange-4: #e16f24; + --color-scale-orange-5: #bc4c00; + --color-scale-orange-6: #953800; + --color-scale-orange-7: #762c00; + --color-scale-orange-8: #5c2200; + --color-scale-orange-9: #471700; + --color-scale-red-0: #FFEBE9; + --color-scale-red-1: #ffcecb; + --color-scale-red-2: #ffaba8; + --color-scale-red-3: #ff8182; + --color-scale-red-4: #fa4549; + --color-scale-red-5: #cf222e; + --color-scale-red-6: #a40e26; + --color-scale-red-7: #82071e; + --color-scale-red-8: #660018; + --color-scale-red-9: #4c0014; + --color-scale-purple-0: #fbefff; + --color-scale-purple-1: #ecd8ff; + --color-scale-purple-2: #d8b9ff; + --color-scale-purple-3: #c297ff; + --color-scale-purple-4: #a475f9; + --color-scale-purple-5: #8250df; + --color-scale-purple-6: #6639ba; + --color-scale-purple-7: #512a97; + --color-scale-purple-8: #3e1f79; + --color-scale-purple-9: #2e1461; + --color-scale-pink-0: #ffeff7; + --color-scale-pink-1: #ffd3eb; + --color-scale-pink-2: #ffadda; + --color-scale-pink-3: #ff80c8; + --color-scale-pink-4: #e85aad; + --color-scale-pink-5: #bf3989; + --color-scale-pink-6: #99286e; + --color-scale-pink-7: #772057; + --color-scale-pink-8: #611347; + --color-scale-pink-9: #4d0336; + --color-scale-coral-0: #FFF0EB; + --color-scale-coral-1: #FFD6CC; + --color-scale-coral-2: #FFB4A1; + --color-scale-coral-3: #FD8C73; + --color-scale-coral-4: #EC6547; + --color-scale-coral-5: #C4432B; + --color-scale-coral-6: #9E2F1C; + --color-scale-coral-7: #801F0F; + --color-scale-coral-8: #691105; + --color-scale-coral-9: #510901 +} + +@media(prefers-color-scheme: dark) { + :root { + --color-canvas-default-transparent: rgba(13,17,23,0); + --color-marketing-icon-primary: #79c0ff; + --color-marketing-icon-secondary: #1f6feb; + --color-diff-blob-addition-num-text: #c9d1d9; + --color-diff-blob-addition-fg: #c9d1d9; + --color-diff-blob-addition-num-bg: rgba(63,185,80,0.3); + --color-diff-blob-addition-line-bg: rgba(46,160,67,0.15); + --color-diff-blob-addition-word-bg: rgba(46,160,67,0.4); + --color-diff-blob-deletion-num-text: #c9d1d9; + --color-diff-blob-deletion-fg: #c9d1d9; + --color-diff-blob-deletion-num-bg: rgba(248,81,73,0.3); + --color-diff-blob-deletion-line-bg: rgba(248,81,73,0.15); + --color-diff-blob-deletion-word-bg: rgba(248,81,73,0.4); + --color-diff-blob-hunk-num-bg: rgba(56,139,253,0.4); + --color-diff-blob-expander-icon: #8b949e; + --color-diff-blob-selected-line-highlight-mix-blend-mode: screen; + --color-diffstat-deletion-border: rgba(240,246,252,0.1); + --color-diffstat-addition-border: rgba(240,246,252,0.1); + --color-diffstat-addition-bg: #3fb950; + --color-search-keyword-hl: rgba(210,153,34,0.4); + --color-prettylights-syntax-comment: #8b949e; + --color-prettylights-syntax-constant: #79c0ff; + --color-prettylights-syntax-entity: #d2a8ff; + --color-prettylights-syntax-storage-modifier-import: #c9d1d9; + --color-prettylights-syntax-entity-tag: #7ee787; + --color-prettylights-syntax-keyword: #ff7b72; + --color-prettylights-syntax-string: #a5d6ff; + --color-prettylights-syntax-variable: #ffa657; + --color-prettylights-syntax-brackethighlighter-unmatched: #f85149; + --color-prettylights-syntax-invalid-illegal-text: #f0f6fc; + --color-prettylights-syntax-invalid-illegal-bg: #8e1519; + --color-prettylights-syntax-carriage-return-text: #f0f6fc; + --color-prettylights-syntax-carriage-return-bg: #b62324; + --color-prettylights-syntax-string-regexp: #7ee787; + --color-prettylights-syntax-markup-list: #f2cc60; + --color-prettylights-syntax-markup-heading: #1f6feb; + --color-prettylights-syntax-markup-italic: #c9d1d9; + --color-prettylights-syntax-markup-bold: #c9d1d9; + --color-prettylights-syntax-markup-deleted-text: #ffdcd7; + --color-prettylights-syntax-markup-deleted-bg: #67060c; + --color-prettylights-syntax-markup-inserted-text: #aff5b4; + --color-prettylights-syntax-markup-inserted-bg: #033a16; + --color-prettylights-syntax-markup-changed-text: #ffdfb6; + --color-prettylights-syntax-markup-changed-bg: #5a1e02; + --color-prettylights-syntax-markup-ignored-text: #c9d1d9; + --color-prettylights-syntax-markup-ignored-bg: #1158c7; + --color-prettylights-syntax-meta-diff-range: #d2a8ff; + --color-prettylights-syntax-brackethighlighter-angle: #8b949e; + --color-prettylights-syntax-sublimelinter-gutter-mark: #484f58; + --color-prettylights-syntax-constant-other-reference-link: #a5d6ff; + --color-codemirror-text: #c9d1d9; + --color-codemirror-bg: #0d1117; + --color-codemirror-gutters-bg: #0d1117; + --color-codemirror-guttermarker-text: #0d1117; + --color-codemirror-guttermarker-subtle-text: #484f58; + --color-codemirror-linenumber-text: #8b949e; + --color-codemirror-cursor: #c9d1d9; + --color-codemirror-selection-bg: rgba(56,139,253,0.4); + --color-codemirror-activeline-bg: rgba(110,118,129,0.1); + --color-codemirror-matchingbracket-text: #c9d1d9; + --color-codemirror-lines-bg: #0d1117; + --color-codemirror-syntax-comment: #8b949e; + --color-codemirror-syntax-constant: #79c0ff; + --color-codemirror-syntax-entity: #d2a8ff; + --color-codemirror-syntax-keyword: #ff7b72; + --color-codemirror-syntax-storage: #ff7b72; + --color-codemirror-syntax-string: #a5d6ff; + --color-codemirror-syntax-support: #79c0ff; + --color-codemirror-syntax-variable: #ffa657; + --color-checks-bg: #010409; + --color-checks-run-border-width: 1px; + --color-checks-container-border-width: 1px; + --color-checks-text-primary: #c9d1d9; + --color-checks-text-secondary: #8b949e; + --color-checks-text-link: #58a6ff; + --color-checks-btn-icon: #8b949e; + --color-checks-btn-hover-icon: #c9d1d9; + --color-checks-btn-hover-bg: rgba(110,118,129,0.1); + --color-checks-input-text: #8b949e; + --color-checks-input-placeholder-text: #484f58; + --color-checks-input-focus-text: #c9d1d9; + --color-checks-input-bg: #161b22; + --color-checks-input-shadow: none; + --color-checks-donut-error: #f85149; + --color-checks-donut-pending: #d29922; + --color-checks-donut-success: #2ea043; + --color-checks-donut-neutral: #8b949e; + --color-checks-dropdown-text: #c9d1d9; + --color-checks-dropdown-bg: #161b22; + --color-checks-dropdown-border: #30363d; + --color-checks-dropdown-shadow: rgba(1,4,9,0.3); + --color-checks-dropdown-hover-text: #c9d1d9; + --color-checks-dropdown-hover-bg: rgba(110,118,129,0.1); + --color-checks-dropdown-btn-hover-text: #c9d1d9; + --color-checks-dropdown-btn-hover-bg: rgba(110,118,129,0.1); + --color-checks-scrollbar-thumb-bg: rgba(110,118,129,0.4); + --color-checks-header-label-text: #8b949e; + --color-checks-header-label-open-text: #c9d1d9; + --color-checks-header-border: #21262d; + --color-checks-header-icon: #8b949e; + --color-checks-line-text: #8b949e; + --color-checks-line-num-text: #484f58; + --color-checks-line-timestamp-text: #484f58; + --color-checks-line-hover-bg: rgba(110,118,129,0.1); + --color-checks-line-selected-bg: rgba(56,139,253,0.15); + --color-checks-line-selected-num-text: #58a6ff; + --color-checks-line-dt-fm-text: #f0f6fc; + --color-checks-line-dt-fm-bg: #9e6a03; + --color-checks-gate-bg: rgba(187,128,9,0.15); + --color-checks-gate-text: #8b949e; + --color-checks-gate-waiting-text: #d29922; + --color-checks-step-header-open-bg: #161b22; + --color-checks-step-error-text: #f85149; + --color-checks-step-warning-text: #d29922; + --color-checks-logline-text: #8b949e; + --color-checks-logline-num-text: #484f58; + --color-checks-logline-debug-text: #a371f7; + --color-checks-logline-error-text: #8b949e; + --color-checks-logline-error-num-text: #484f58; + --color-checks-logline-error-bg: rgba(248,81,73,0.15); + --color-checks-logline-warning-text: #8b949e; + --color-checks-logline-warning-num-text: #d29922; + --color-checks-logline-warning-bg: rgba(187,128,9,0.15); + --color-checks-logline-command-text: #58a6ff; + --color-checks-logline-section-text: #3fb950; + --color-checks-ansi-black: #0d1117; + --color-checks-ansi-black-bright: #161b22; + --color-checks-ansi-white: #b1bac4; + --color-checks-ansi-white-bright: #b1bac4; + --color-checks-ansi-gray: #6e7681; + --color-checks-ansi-red: #ff7b72; + --color-checks-ansi-red-bright: #ffa198; + --color-checks-ansi-green: #3fb950; + --color-checks-ansi-green-bright: #56d364; + --color-checks-ansi-yellow: #d29922; + --color-checks-ansi-yellow-bright: #e3b341; + --color-checks-ansi-blue: #58a6ff; + --color-checks-ansi-blue-bright: #79c0ff; + --color-checks-ansi-magenta: #bc8cff; + --color-checks-ansi-magenta-bright: #d2a8ff; + --color-checks-ansi-cyan: #76e3ea; + --color-checks-ansi-cyan-bright: #b3f0ff; + --color-project-header-bg: #0d1117; + --color-project-sidebar-bg: #161b22; + --color-project-gradient-in: #161b22; + --color-project-gradient-out: rgba(22,27,34,0); + --color-mktg-success: rgba(41,147,61,1); + --color-mktg-info: rgba(42,123,243,1); + --color-mktg-bg-shade-gradient-top: rgba(1,4,9,0.065); + --color-mktg-bg-shade-gradient-bottom: rgba(1,4,9,0); + --color-mktg-btn-bg-top: hsla(228,82%,66%,1); + --color-mktg-btn-bg-bottom: #4969ed; + --color-mktg-btn-bg-overlay-top: hsla(228,74%,59%,1); + --color-mktg-btn-bg-overlay-bottom: #3355e0; + --color-mktg-btn-text: #f0f6fc; + --color-mktg-btn-primary-bg-top: hsla(137,56%,46%,1); + --color-mktg-btn-primary-bg-bottom: #2ea44f; + --color-mktg-btn-primary-bg-overlay-top: hsla(134,60%,38%,1); + --color-mktg-btn-primary-bg-overlay-bottom: #22863a; + --color-mktg-btn-primary-text: #f0f6fc; + --color-mktg-btn-enterprise-bg-top: hsla(249,100%,72%,1); + --color-mktg-btn-enterprise-bg-bottom: #6f57ff; + --color-mktg-btn-enterprise-bg-overlay-top: hsla(248,65%,63%,1); + --color-mktg-btn-enterprise-bg-overlay-bottom: #614eda; + --color-mktg-btn-enterprise-text: #f0f6fc; + --color-mktg-btn-outline-text: #f0f6fc; + --color-mktg-btn-outline-border: rgba(240,246,252,0.3); + --color-mktg-btn-outline-hover-text: #f0f6fc; + --color-mktg-btn-outline-hover-border: rgba(240,246,252,0.5); + --color-mktg-btn-outline-focus-border: #f0f6fc; + --color-mktg-btn-outline-focus-border-inset: rgba(240,246,252,0.5); + --color-mktg-btn-dark-text: #f0f6fc; + --color-mktg-btn-dark-border: rgba(240,246,252,0.3); + --color-mktg-btn-dark-hover-text: #f0f6fc; + --color-mktg-btn-dark-hover-border: rgba(240,246,252,0.5); + --color-mktg-btn-dark-focus-border: #f0f6fc; + --color-mktg-btn-dark-focus-border-inset: rgba(240,246,252,0.5); + --color-avatar-bg: rgba(240,246,252,0.1); + --color-avatar-border: rgba(240,246,252,0.1); + --color-avatar-stack-fade: #30363d; + --color-avatar-stack-fade-more: #21262d; + --color-avatar-child-shadow: -2px -2px 0 #0d1117; + --color-topic-tag-border: rgba(0,0,0,0); + --color-select-menu-backdrop-border: #484f58; + --color-select-menu-tap-highlight: rgba(48,54,61,0.5); + --color-select-menu-tap-focus-bg: #0c2d6b; + --color-overlay-shadow: 0 0 0 1px #30363d, 0 16px 32px rgba(1,4,9,0.85); + --color-header-text: rgba(240,246,252,0.7); + --color-header-bg: #161b22; + --color-header-logo: #f0f6fc; + --color-header-search-bg: #0d1117; + --color-header-search-border: #30363d; + --color-sidenav-selected-bg: #21262d; + --color-menu-bg-active: #161b22; + --color-control-transparent-bg-hover: #656c7633; + --color-input-disabled-bg: rgba(110,118,129,0); + --color-timeline-badge-bg: #21262d; + --color-ansi-black: #484f58; + --color-ansi-black-bright: #6e7681; + --color-ansi-white: #b1bac4; + --color-ansi-white-bright: #f0f6fc; + --color-ansi-gray: #6e7681; + --color-ansi-red: #ff7b72; + --color-ansi-red-bright: #ffa198; + --color-ansi-green: #3fb950; + --color-ansi-green-bright: #56d364; + --color-ansi-yellow: #d29922; + --color-ansi-yellow-bright: #e3b341; + --color-ansi-blue: #58a6ff; + --color-ansi-blue-bright: #79c0ff; + --color-ansi-magenta: #bc8cff; + --color-ansi-magenta-bright: #d2a8ff; + --color-ansi-cyan: #39c5cf; + --color-ansi-cyan-bright: #56d4dd; + --color-btn-text: #c9d1d9; + --color-btn-bg: #21262d; + --color-btn-border: rgba(240,246,252,0.1); + --color-btn-shadow: 0 0 transparent; + --color-btn-inset-shadow: 0 0 transparent; + --color-btn-hover-bg: #30363d; + --color-btn-hover-border: #8b949e; + --color-btn-active-bg: hsla(212,12%,18%,1); + --color-btn-active-border: #6e7681; + --color-btn-selected-bg: #161b22; + --color-btn-focus-bg: #21262d; + --color-btn-focus-border: #8b949e; + --color-btn-focus-shadow: 0 0 0 3px rgba(139,148,158,0.3); + --color-btn-shadow-active: inset 0 0.15em 0.3em rgba(1,4,9,0.15); + --color-btn-shadow-input-focus: 0 0 0 0.2em rgba(31,111,235,0.3); + --color-btn-counter-bg: #30363d; + --color-btn-primary-text: #ffffff; + --color-btn-primary-bg: #238636; + --color-btn-primary-border: rgba(240,246,252,0.1); + --color-btn-primary-shadow: 0 0 transparent; + --color-btn-primary-inset-shadow: 0 0 transparent; + --color-btn-primary-hover-bg: #2ea043; + --color-btn-primary-hover-border: rgba(240,246,252,0.1); + --color-btn-primary-selected-bg: #238636; + --color-btn-primary-selected-shadow: 0 0 transparent; + --color-btn-primary-disabled-text: rgba(240,246,252,0.5); + --color-btn-primary-disabled-bg: rgba(35,134,54,0.6); + --color-btn-primary-disabled-border: rgba(240,246,252,0.1); + --color-btn-primary-focus-bg: #238636; + --color-btn-primary-focus-border: rgba(240,246,252,0.1); + --color-btn-primary-focus-shadow: 0 0 0 3px rgba(46,164,79,0.4); + --color-btn-primary-icon: #f0f6fc; + --color-btn-primary-counter-bg: rgba(240,246,252,0.2); + --color-btn-outline-text: #58a6ff; + --color-btn-outline-hover-text: #58a6ff; + --color-btn-outline-hover-bg: #30363d; + --color-btn-outline-hover-border: rgba(240,246,252,0.1); + --color-btn-outline-hover-shadow: 0 1px 0 rgba(1,4,9,0.1); + --color-btn-outline-hover-inset-shadow: inset 0 1px 0 rgba(240,246,252,0.03); + --color-btn-outline-hover-counter-bg: rgba(240,246,252,0.2); + --color-btn-outline-selected-text: #f0f6fc; + --color-btn-outline-selected-bg: #0d419d; + --color-btn-outline-selected-border: rgba(240,246,252,0.1); + --color-btn-outline-selected-shadow: 0 0 transparent; + --color-btn-outline-disabled-text: rgba(88,166,255,0.5); + --color-btn-outline-disabled-bg: #0d1117; + --color-btn-outline-disabled-counter-bg: rgba(31,111,235,0.05); + --color-btn-outline-focus-border: rgba(240,246,252,0.1); + --color-btn-outline-focus-shadow: 0 0 0 3px rgba(17,88,199,0.4); + --color-btn-outline-counter-bg: rgba(31,111,235,0.1); + --color-btn-danger-text: #f85149; + --color-btn-danger-hover-text: #f0f6fc; + --color-btn-danger-hover-bg: #da3633; + --color-btn-danger-hover-border: #f85149; + --color-btn-danger-hover-shadow: 0 0 transparent; + --color-btn-danger-hover-inset-shadow: 0 0 transparent; + --color-btn-danger-hover-icon: #f0f6fc; + --color-btn-danger-hover-counter-bg: rgba(255,255,255,0.2); + --color-btn-danger-selected-text: #ffffff; + --color-btn-danger-selected-bg: #b62324; + --color-btn-danger-selected-border: #ff7b72; + --color-btn-danger-selected-shadow: 0 0 transparent; + --color-btn-danger-disabled-text: rgba(248,81,73,0.5); + --color-btn-danger-disabled-bg: #0d1117; + --color-btn-danger-disabled-counter-bg: rgba(218,54,51,0.05); + --color-btn-danger-focus-border: #f85149; + --color-btn-danger-focus-shadow: 0 0 0 3px rgba(248,81,73,0.4); + --color-btn-danger-counter-bg: rgba(218,54,51,0.1); + --color-btn-danger-icon: #f85149; + --color-underlinenav-icon: #484f58; + --color-underlinenav-border-hover: rgba(110,118,129,0.4); + --color-fg-default: #c9d1d9; + --color-fg-muted: #8b949e; + --color-fg-subtle: #484f58; + --color-fg-on-emphasis: #f0f6fc; + --color-canvas-default: #0d1117; + --color-canvas-overlay: #161b22; + --color-canvas-inset: #010409; + --color-canvas-subtle: #161b22; + --color-border-default: #30363d; + --color-border-muted: #21262d; + --color-border-subtle: rgba(240,246,252,0.1); + --color-shadow-small: 0 0 transparent; + --color-shadow-medium: 0 3px 6px #010409; + --color-shadow-large: 0 8px 24px #010409; + --color-shadow-extra-large: 0 12px 48px #010409; + --color-neutral-emphasis-plus: #6e7681; + --color-neutral-emphasis: #6e7681; + --color-neutral-muted: rgba(110,118,129,0.4); + --color-neutral-subtle: rgba(110,118,129,0.1); + --color-accent-fg: #58a6ff; + --color-accent-emphasis: #1f6feb; + --color-accent-muted: rgba(56,139,253,0.4); + --color-accent-subtle: rgba(56,139,253,0.15); + --color-success-fg: #3fb950; + --color-success-emphasis: #238636; + --color-success-muted: rgba(46,160,67,0.4); + --color-success-subtle: rgba(46,160,67,0.15); + --color-attention-fg: #d29922; + --color-attention-emphasis: #9e6a03; + --color-attention-muted: rgba(187,128,9,0.4); + --color-attention-subtle: rgba(187,128,9,0.15); + --color-severe-fg: #db6d28; + --color-severe-emphasis: #bd561d; + --color-severe-muted: rgba(219,109,40,0.4); + --color-severe-subtle: rgba(219,109,40,0.15); + --color-danger-fg: #f85149; + --color-danger-emphasis: #da3633; + --color-danger-muted: rgba(248,81,73,0.4); + --color-danger-subtle: rgba(248,81,73,0.15); + --color-done-fg: #a371f7; + --color-done-emphasis: #8957e5; + --color-done-muted: rgba(163,113,247,0.4); + --color-done-subtle: rgba(163,113,247,0.15); + --color-sponsors-fg: #db61a2; + --color-sponsors-emphasis: #bf4b8a; + --color-sponsors-muted: rgba(219,97,162,0.4); + --color-sponsors-subtle: rgba(219,97,162,0.15); + --color-primer-canvas-backdrop: rgba(1,4,9,0.8); + --color-primer-canvas-sticky: rgba(13,17,23,0.95); + --color-primer-border-active: #F78166; + --color-primer-border-contrast: rgba(240,246,252,0.2); + --color-primer-shadow-highlight: 0 0 transparent; + --color-primer-shadow-inset: 0 0 transparent; + --color-primer-shadow-focus: 0 0 0 3px #0c2d6b; + --color-scale-black: #010409; + --color-scale-white: #f0f6fc; + --color-scale-gray-0: #f0f6fc; + --color-scale-gray-1: #c9d1d9; + --color-scale-gray-2: #b1bac4; + --color-scale-gray-3: #8b949e; + --color-scale-gray-4: #6e7681; + --color-scale-gray-5: #484f58; + --color-scale-gray-6: #30363d; + --color-scale-gray-7: #21262d; + --color-scale-gray-8: #161b22; + --color-scale-gray-9: #0d1117; + --color-scale-blue-0: #cae8ff; + --color-scale-blue-1: #a5d6ff; + --color-scale-blue-2: #79c0ff; + --color-scale-blue-3: #58a6ff; + --color-scale-blue-4: #388bfd; + --color-scale-blue-5: #1f6feb; + --color-scale-blue-6: #1158c7; + --color-scale-blue-7: #0d419d; + --color-scale-blue-8: #0c2d6b; + --color-scale-blue-9: #051d4d; + --color-scale-green-0: #aff5b4; + --color-scale-green-1: #7ee787; + --color-scale-green-2: #56d364; + --color-scale-green-3: #3fb950; + --color-scale-green-4: #2ea043; + --color-scale-green-5: #238636; + --color-scale-green-6: #196c2e; + --color-scale-green-7: #0f5323; + --color-scale-green-8: #033a16; + --color-scale-green-9: #04260f; + --color-scale-yellow-0: #f8e3a1; + --color-scale-yellow-1: #f2cc60; + --color-scale-yellow-2: #e3b341; + --color-scale-yellow-3: #d29922; + --color-scale-yellow-4: #bb8009; + --color-scale-yellow-5: #9e6a03; + --color-scale-yellow-6: #845306; + --color-scale-yellow-7: #693e00; + --color-scale-yellow-8: #4b2900; + --color-scale-yellow-9: #341a00; + --color-scale-orange-0: #ffdfb6; + --color-scale-orange-1: #ffc680; + --color-scale-orange-2: #ffa657; + --color-scale-orange-3: #f0883e; + --color-scale-orange-4: #db6d28; + --color-scale-orange-5: #bd561d; + --color-scale-orange-6: #9b4215; + --color-scale-orange-7: #762d0a; + --color-scale-orange-8: #5a1e02; + --color-scale-orange-9: #3d1300; + --color-scale-red-0: #ffdcd7; + --color-scale-red-1: #ffc1ba; + --color-scale-red-2: #ffa198; + --color-scale-red-3: #ff7b72; + --color-scale-red-4: #f85149; + --color-scale-red-5: #da3633; + --color-scale-red-6: #b62324; + --color-scale-red-7: #8e1519; + --color-scale-red-8: #67060c; + --color-scale-red-9: #490202; + --color-scale-purple-0: #eddeff; + --color-scale-purple-1: #e2c5ff; + --color-scale-purple-2: #d2a8ff; + --color-scale-purple-3: #bc8cff; + --color-scale-purple-4: #a371f7; + --color-scale-purple-5: #8957e5; + --color-scale-purple-6: #6e40c9; + --color-scale-purple-7: #553098; + --color-scale-purple-8: #3c1e70; + --color-scale-purple-9: #271052; + --color-scale-pink-0: #ffdaec; + --color-scale-pink-1: #ffbedd; + --color-scale-pink-2: #ff9bce; + --color-scale-pink-3: #f778ba; + --color-scale-pink-4: #db61a2; + --color-scale-pink-5: #bf4b8a; + --color-scale-pink-6: #9e3670; + --color-scale-pink-7: #7d2457; + --color-scale-pink-8: #5e103e; + --color-scale-pink-9: #42062a; + --color-scale-coral-0: #FFDDD2; + --color-scale-coral-1: #FFC2B2; + --color-scale-coral-2: #FFA28B; + --color-scale-coral-3: #F78166; + --color-scale-coral-4: #EA6045; + --color-scale-coral-5: #CF462D; + --color-scale-coral-6: #AC3220; + --color-scale-coral-7: #872012; + --color-scale-coral-8: #640D04; + --color-scale-coral-9: #460701 + } +} diff --git a/packages/extension/src/ui/connect.css b/packages/extension/src/ui/connect.css new file mode 100644 index 0000000000000..dd0081a28f1d6 --- /dev/null +++ b/packages/extension/src/ui/connect.css @@ -0,0 +1,278 @@ +/* + Copyright (c) Microsoft Corporation. + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +*/ + +body { + margin: 0; + padding: 0; +} + +/* Base styles */ +.app-container { + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "Noto Sans", Helvetica, Arial, sans-serif; + background-color: #ffffff; + color: #1f2328; + margin: 0; + padding: 16px; + min-height: 100vh; + font-size: 14px; +} + +.content-wrapper { + max-width: 600px; + margin: 0 auto; +} + +/* Status Banner */ +.status-container { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 16px; + padding-right: 12px; +} + +.status-banner { + padding: 12px; + font-size: 14px; + font-weight: 500; + display: flex; + align-items: center; + gap: 8px; + flex: 1; +} + +.status-banner.connected { + color: #1f2328; +} + +.status-banner.connected::before { + content: "\2705"; + margin-right: 8px; +} + +.status-banner.error { + color: #1f2328; +} + +.status-banner.error::before { + content: "\274C"; + margin-right: 8px; +} + +/* Warning banner */ +.warning-banner { + margin: 0 12px 16px 12px; + padding: 12px; + background-color: #fff8e1; + border: 1px solid #f0c674; + border-radius: 6px; + font-size: 13px; + line-height: 1.5; + color: #5c4408; +} + +.warning-banner strong { + color: #1f2328; +} + +/* Buttons */ +.button-container { + margin-bottom: 16px; + display: flex; + justify-content: flex-end; + padding-right: 12px; +} + +.button { + padding: 8px 16px; + border-radius: 6px; + border: none; + font-size: 14px; + font-weight: 500; + cursor: pointer; + display: inline-flex; + align-items: center; + justify-content: center; + text-decoration: none; + margin-right: 8px; + min-width: 90px; +} + +.button.primary { + background-color: #f8f9fa; + color: #3c4043; + border: 1px solid #dadce0; +} + +.button.primary:hover { + background-color: #f1f3f4; + border-color: #dadce0; + box-shadow: 0 1px 2px 0 rgba(60,64,67,.1); +} + +.button.default { + background-color: #f6f8fa; + color: #24292f; +} + +.button.default:hover { + background-color: #f3f4f6; +} + +.button.reject { + background-color: #da3633; + color: #ffffff; + border: 1px solid #da3633; +} + +.button.reject:hover { + background-color: #c73836; + border-color: #c73836; +} + +/* Tab selection */ +.tab-section-title { + padding-left: 12px; + font-size: 12px; + font-weight: 400; + margin-bottom: 12px; + color: #656d76; +} + +.tab-item { + display: flex; + align-items: center; + padding: 12px; + margin-bottom: 8px; + background-color: #ffffff; + cursor: pointer; + border-radius: 6px; + transition: background-color 0.2s ease; +} + +.tab-item:hover { + background-color: #f8f9fa; +} + +.tab-item.selected { + background-color: #f6f8fa; +} + +.tab-item.disabled { + cursor: not-allowed; + opacity: 0.5; +} + +.tab-radio { + margin-right: 12px; + flex-shrink: 0; +} + +.tab-favicon { + width: 16px; + height: 16px; + margin-right: 8px; + flex-shrink: 0; +} + +.tab-content { + flex: 1; + min-width: 0; +} + +.tab-title { + font-weight: 500; + color: #1f2328; + margin-bottom: 2px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.tab-url { + font-size: 12px; + color: #656d76; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +/* Link-style button */ +.link-button { + background: none; + border: none; + color: #0066cc; + text-decoration: underline; + cursor: pointer; + padding: 0; + font: inherit; +} + +/* Auth token section */ +.auth-token-section { + margin: 16px 0; + padding: 16px; + background-color: #f6f8fa; + border-radius: 6px; +} + +.auth-token-description { + font-size: 12px; + color: #656d76; + margin-bottom: 12px; +} + +.auth-token-container { + display: flex; + align-items: center; + gap: 8px; + background-color: #ffffff; + padding: 8px; +} + +.auth-token-code { + font-family: 'SFMono-Regular', Consolas, 'Liberation Mono', Menlo, monospace; + font-size: 12px; + color: #1f2328; + border: none; + flex: 1; + padding: 0; + word-break: break-all; +} + +.auth-token-refresh { + flex: none; + height: 24px; + width: 24px; + border: none; + outline: none; + color: var(--color-fg-muted); + background: transparent; + padding: 4px; + cursor: pointer; + display: inline-flex; + align-items: center; + justify-content: center; + border-radius: 4px; +} + +.auth-token-refresh svg { + margin: 0; +} + +.auth-token-refresh:not(:disabled):hover { + background-color: var(--color-btn-selected-bg); +} diff --git a/packages/extension/src/ui/connect.html b/packages/extension/src/ui/connect.html new file mode 100644 index 0000000000000..2c2dcd2fbbcda --- /dev/null +++ b/packages/extension/src/ui/connect.html @@ -0,0 +1,29 @@ + + + + + Welcome + + + + + + +
+ + + \ No newline at end of file diff --git a/packages/extension/src/ui/connect.tsx b/packages/extension/src/ui/connect.tsx new file mode 100644 index 0000000000000..beecffe26a44e --- /dev/null +++ b/packages/extension/src/ui/connect.tsx @@ -0,0 +1,274 @@ +/** + * Copyright (c) Microsoft Corporation. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import React, { useCallback, useEffect, useState } from 'react'; +import { createRoot } from 'react-dom/client'; +import { Button, TabItem } from './tabItem'; +import { AuthTokenSection, getOrCreateAuthToken } from './authToken'; + +import type { TabInfo } from './tabItem'; + +type Status = + | { type: 'connecting'; message: string } + | { type: 'connected'; message: string } + | { type: 'error'; message: string } + | { type: 'error'; versionMismatch: { extensionVersion: string; } }; + +const SUPPORTED_PROTOCOL_VERSION = 2; + +const ConnectApp: React.FC = () => { + const [tabs, setTabs] = useState([]); + const [status, setStatus] = useState(null); + const [showButtons, setShowButtons] = useState(true); + const [showTabList, setShowTabList] = useState(true); + const [clientInfo, setClientInfo] = useState('unknown'); + const [mcpRelayUrl, setMcpRelayUrl] = useState(''); + const [newTab, setNewTab] = useState(false); + + useEffect(() => { + const runAsync = async () => { + const params = new URLSearchParams(window.location.search); + const relayUrl = params.get('mcpRelayUrl'); + + if (!relayUrl) { + handleReject('Missing mcpRelayUrl parameter in URL.'); + return; + } + + try { + const host = new URL(relayUrl).hostname; + if (host !== '127.0.0.1' && host !== '[::1]') { + handleReject(`MCP extension only allows loopback connections (127.0.0.1 or [::1]). Received host: ${host}`); + return; + } + } catch (e) { + handleReject(`Invalid mcpRelayUrl parameter in URL: ${relayUrl}. ${e}`); + return; + } + + setMcpRelayUrl(relayUrl); + + try { + const client = JSON.parse(params.get('client') || '{}'); + const info = `${client.name || 'unknown'}`; + setClientInfo(info); + setStatus({ + type: 'connecting', + message: `"${info}" is trying to connect to the Playwright Extension.` + }); + } catch (e) { + setStatus({ type: 'error', message: 'Failed to parse client version.' }); + return; + } + + const parsedVersion = parseInt(params.get('protocolVersion') ?? '', 10); + const requestedVersion = isNaN(parsedVersion) ? 1 : parsedVersion; + if (requestedVersion > SUPPORTED_PROTOCOL_VERSION) { + const extensionVersion = chrome.runtime.getManifest().version; + setShowButtons(false); + setShowTabList(false); + setStatus({ + type: 'error', + versionMismatch: { + extensionVersion, + } + }); + return; + } + + const expectedToken = getOrCreateAuthToken(); + const token = params.get('token'); + if (token === expectedToken) { + await connectToMCPRelay(relayUrl, requestedVersion); + await handleConnectToTab(); + return; + } + if (token) { + handleReject('Invalid token provided.'); + return; + } + + await connectToMCPRelay(relayUrl, requestedVersion); + + // If this is a browser_navigate command, hide the tab list and show simple allow/reject + if (params.get('newTab') === 'true') { + setNewTab(true); + setShowTabList(false); + } else { + await loadTabs(); + } + }; + void runAsync(); + }, []); + + const handleReject = useCallback((message: string) => { + setShowButtons(false); + setShowTabList(false); + setStatus({ type: 'error', message }); + }, []); + + const connectToMCPRelay = useCallback(async (mcpRelayUrl: string, protocolVersion: number) => { + const response = await chrome.runtime.sendMessage({ type: 'connectToMCPRelay', mcpRelayUrl, protocolVersion }); + if (!response.success) + handleReject(response.error); + }, [handleReject]); + + const loadTabs = useCallback(async () => { + const response = await chrome.runtime.sendMessage({ type: 'getTabs' }); + if (response.success) + setTabs(response.tabs); + else + setStatus({ type: 'error', message: 'Failed to load tabs: ' + response.error }); + }, []); + + const handleConnectToTab = useCallback(async (tab?: TabInfo) => { + setShowButtons(false); + setShowTabList(false); + + try { + const response = await chrome.runtime.sendMessage({ + type: 'connectToTab', + mcpRelayUrl, + tabId: tab?.id, + windowId: tab?.windowId, + }); + + if (response?.success) { + setStatus({ type: 'connected', message: `"${clientInfo}" connected.` }); + } else { + setStatus({ + type: 'error', + message: response?.error || `"${clientInfo}" failed to connect.` + }); + } + } catch (e) { + setStatus({ + type: 'error', + message: `"${clientInfo}" failed to connect: ${e}` + }); + } + }, [clientInfo, mcpRelayUrl]); + + useEffect(() => { + const listener = (message: any) => { + if (message.type === 'pendingConnectionClosed') { + handleReject('Pending client connection closed.'); + document.title = 'Playwright Extension'; + } + }; + chrome.runtime.onMessage.addListener(listener); + return () => { + chrome.runtime.onMessage.removeListener(listener); + }; + }, [handleReject]); + + return ( +
+
+ {status && ( +
+ + {showButtons && ( +
+ {newTab ? ( + <> + + + + ) : ( + + )} +
+ )} +
+ )} + + {status?.type === 'connecting' && ( +
+ ⚠️ Warning: Allowing this connection exposes the entire browser to the client, + including any signed-in sessions, cookies, and content in other tabs and windows. + Once approved, the client may also be able to reconnect later without showing this dialog again, + unless you regenerate the token below and then restart the browser. +
+ )} + + {status?.type === 'connecting' && ( + + )} + + {showTabList && ( +
+
+ Select the default tab for this connection: +
+
+ {tabs.map(tab => ( + handleConnectToTab(tab)}> + Connect + + } + /> + ))} +
+
+ )} +
+
+ ); +}; + +const VersionMismatchError: React.FC<{ extensionVersion: string }> = ({ extensionVersion }) => { + const readmeUrl = 'https://github.com/microsoft/playwright-mcp/blob/main/packages/extension/README.md'; + const chromeWebStoreUrl = 'https://chromewebstore.google.com/detail/playwright-mcp-bridge/mmlmfjhmonkocbjadbfplnigmagldckm'; + return ( +
+ Playwright MCP version trying to connect requires newer extension version (current version: {extensionVersion}).{' '} + Update Playwright Extension from the Chrome Web Store to the latest version.{' '} + See installation instructions for more details. +
+ ); +}; + +const StatusBanner: React.FC<{ status: Status }> = ({ status }) => { + return ( +
+ {'versionMismatch' in status ? ( + + ) : ( + status.message + )} +
+ ); +}; + +// Initialize the React app +const container = document.getElementById('root'); +if (container) { + const root = createRoot(container); + root.render(); +} diff --git a/packages/extension/src/ui/copyToClipboard.css b/packages/extension/src/ui/copyToClipboard.css new file mode 100644 index 0000000000000..d9299473e4ca6 --- /dev/null +++ b/packages/extension/src/ui/copyToClipboard.css @@ -0,0 +1,39 @@ +/* + Copyright (c) Microsoft Corporation. + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +*/ + +.copy-icon { + flex: none; + height: 24px; + width: 24px; + border: none; + outline: none; + color: var(--color-fg-muted); + background: transparent; + padding: 4px; + cursor: pointer; + display: inline-flex; + align-items: center; + justify-content: center; + border-radius: 4px; +} + +.copy-icon svg { + margin: 0; +} + +.copy-icon:not(:disabled):hover { + background-color: var(--color-btn-selected-bg); +} diff --git a/packages/extension/src/ui/copyToClipboard.tsx b/packages/extension/src/ui/copyToClipboard.tsx new file mode 100644 index 0000000000000..487eabaf7ed20 --- /dev/null +++ b/packages/extension/src/ui/copyToClipboard.tsx @@ -0,0 +1,54 @@ +/** + * Copyright (c) Microsoft Corporation. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import * as React from 'react'; +import * as icons from './icons'; +import './copyToClipboard.css'; + +type CopyToClipboardProps = { + value: string; +}; + +/** + * A copy to clipboard button. + */ +export const CopyToClipboard: React.FunctionComponent = ({ value }) => { + type IconType = 'copy' | 'check' | 'cross'; + const [icon, setIcon] = React.useState('copy'); + + React.useEffect(() => { + setIcon('copy'); + }, [value]); + + React.useEffect(() => { + if (icon === 'check') { + const timeout = setTimeout(() => { + setIcon('copy'); + }, 3000); + return () => clearTimeout(timeout); + } + }, [icon]); + + const handleCopy = React.useCallback(() => { + navigator.clipboard.writeText(value).then(() => { + setIcon('check'); + }, () => { + setIcon('cross'); + }); + }, [value]); + const iconElement = icon === 'check' ? icons.check() : icon === 'cross' ? icons.cross() : icons.copy(); + return ; +}; diff --git a/packages/extension/src/ui/icons.css b/packages/extension/src/ui/icons.css new file mode 100644 index 0000000000000..8abcf98b7b4d9 --- /dev/null +++ b/packages/extension/src/ui/icons.css @@ -0,0 +1,32 @@ +/* + Copyright (c) Microsoft Corporation. + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +*/ + +.octicon { + display: inline-block; + overflow: visible !important; + vertical-align: text-bottom; + fill: currentColor; + margin-right: 7px; + flex: none; +} + +.color-icon-success { + color: var(--color-success-fg) !important; +} + +.color-text-danger { + color: var(--color-danger-fg) !important; +} diff --git a/packages/extension/src/ui/icons.tsx b/packages/extension/src/ui/icons.tsx new file mode 100644 index 0000000000000..55093c003e230 --- /dev/null +++ b/packages/extension/src/ui/icons.tsx @@ -0,0 +1,49 @@ +/* + Copyright (c) Microsoft Corporation. + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +*/ + +import './icons.css'; +import './colors.css'; + +export const cross = () => { + return ; +}; + +export const check = () => { + return ; +}; + +export const copy = () => { + return ; +}; + +export const refresh = () => { + return ; +}; + +export const chevronDown = () => { + return ; +}; diff --git a/packages/extension/src/ui/status.html b/packages/extension/src/ui/status.html new file mode 100644 index 0000000000000..18cb360d81193 --- /dev/null +++ b/packages/extension/src/ui/status.html @@ -0,0 +1,13 @@ + + + + + + Playwright Extension Status + + + +
+ + + \ No newline at end of file diff --git a/packages/extension/src/ui/status.tsx b/packages/extension/src/ui/status.tsx new file mode 100644 index 0000000000000..a1d07070b598d --- /dev/null +++ b/packages/extension/src/ui/status.tsx @@ -0,0 +1,100 @@ +/** + * Copyright (c) Microsoft Corporation. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import React, { useState, useEffect } from 'react'; +import { createRoot } from 'react-dom/client'; +import { Button, TabItem } from './tabItem'; + +import type { TabInfo } from './tabItem'; +import { AuthTokenSection } from './authToken'; + +const StatusApp: React.FC = () => { + const [connectedTabs, setConnectedTabs] = useState([]); + + useEffect(() => { + void loadStatus(); + }, []); + + const loadStatus = async () => { + const { connectedTabIds } = await chrome.runtime.sendMessage({ type: 'getConnectionStatus' }); + const tabs: TabInfo[] = []; + for (const tabId of (connectedTabIds as number[] ?? [])) { + try { + const tab = await chrome.tabs.get(tabId); + tabs.push({ + id: tab.id!, + windowId: tab.windowId!, + title: tab.title!, + url: tab.url!, + favIconUrl: tab.favIconUrl + }); + } catch { + // Tab may have been closed. + } + } + setConnectedTabs(tabs); + }; + + const openTab = async (tabId: number) => { + await chrome.tabs.update(tabId, { active: true }); + window.close(); + }; + + const disconnect = async () => { + await chrome.runtime.sendMessage({ type: 'disconnect' }); + window.close(); + }; + + return ( +
+
+ {connectedTabs.length > 0 ? ( +
+
+ {connectedTabs.length === 1 ? 'Page connected to Playwright client:' : 'Pages connected to Playwright client:'} +
+
+ {connectedTabs.map((tab, index) => ( + + Disconnect + + ) : undefined} + onClick={() => openTab(tab.id)} + /> + ))} +
+
+ ) : ( +
+ No MCP clients are currently connected. +
+ )} + +
+
+ ); +}; + +// Initialize the React app +const container = document.getElementById('root'); +if (container) { + const root = createRoot(container); + root.render(); +} diff --git a/packages/extension/src/ui/tabItem.tsx b/packages/extension/src/ui/tabItem.tsx new file mode 100644 index 0000000000000..148374292f5b9 --- /dev/null +++ b/packages/extension/src/ui/tabItem.tsx @@ -0,0 +1,67 @@ +/** + * Copyright (c) Microsoft Corporation. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import React from 'react'; + +export interface TabInfo { + id: number; + windowId: number; + title: string; + url: string; + favIconUrl?: string; +} + +export const Button: React.FC<{ variant: 'primary' | 'default' | 'reject'; onClick: () => void; children: React.ReactNode }> = ({ + variant, + onClick, + children +}) => { + return ( + + ); +}; + + +export interface TabItemProps { + tab: TabInfo; + onClick?: () => void; + button?: React.ReactNode; +} + +export const TabItem: React.FC = ({ + tab, + onClick, + button +}) => { + return ( +
+ '} + alt='' + className='tab-favicon' + /> +
+
+ {tab.title || 'Untitled'} +
+
{tab.url}
+
+ {button} +
+ ); +}; diff --git a/packages/extension/src/ui/tsconfig.json b/packages/extension/src/ui/tsconfig.json new file mode 100644 index 0000000000000..77839b62d222b --- /dev/null +++ b/packages/extension/src/ui/tsconfig.json @@ -0,0 +1,4 @@ +// Help VSCode to find right tsconfig file. +{ + "extends": "../../tsconfig.ui.json" +} diff --git a/packages/extension/tsconfig.json b/packages/extension/tsconfig.json new file mode 100644 index 0000000000000..9c22b0bca0018 --- /dev/null +++ b/packages/extension/tsconfig.json @@ -0,0 +1,22 @@ +{ + "compilerOptions": { + "target": "ESNext", + "esModuleInterop": true, + "moduleResolution": "node", + "strict": true, + "module": "ESNext", + "rootDir": "src", + "outDir": "./dist/lib", + "resolveJsonModule": true, + "types": ["chrome"], + "jsx": "react-jsx", + "jsxImportSource": "react", + "noEmit": true + }, + "include": [ + "src", + ], + "exclude": [ + "src/ui", + ] +} diff --git a/packages/extension/tsconfig.ui.json b/packages/extension/tsconfig.ui.json new file mode 100644 index 0000000000000..f62dd8a3f74c3 --- /dev/null +++ b/packages/extension/tsconfig.ui.json @@ -0,0 +1,19 @@ +{ + "compilerOptions": { + "target": "ESNext", + "esModuleInterop": true, + "moduleResolution": "node", + "strict": true, + "module": "ESNext", + "rootDir": "src", + "outDir": "./lib", + "resolveJsonModule": true, + "types": ["chrome"], + "jsx": "react-jsx", + "jsxImportSource": "react", + "noEmit": true, + }, + "include": [ + "src/ui", + ], +} diff --git a/packages/extension/vite.config.mts b/packages/extension/vite.config.mts new file mode 100644 index 0000000000000..89ec56c6898a2 --- /dev/null +++ b/packages/extension/vite.config.mts @@ -0,0 +1,54 @@ +/** + * Copyright (c) Microsoft Corporation. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { resolve } from 'path'; +import { defineConfig } from 'vite'; +import react from '@vitejs/plugin-react'; +import { viteStaticCopy } from 'vite-plugin-static-copy'; + +// https://vitejs.dev/config/ +export default defineConfig({ + plugins: [ + react(), + viteStaticCopy({ + targets: [ + { + src: '../../icons/*', + dest: 'icons' + }, + { + src: '../../manifest.json', + dest: '.' + } + ] + }) + ], + root: resolve(__dirname, 'src/ui'), + build: { + outDir: resolve(__dirname, 'dist/'), + emptyOutDir: false, + minify: false, + rollupOptions: { + input: ['src/ui/connect.html', 'src/ui/status.html'], + output: { + manualChunks: undefined, + entryFileNames: 'lib/ui/[name].js', + chunkFileNames: 'lib/ui/[name].js', + assetFileNames: 'lib/ui/[name].[ext]' + } + } + } +}); diff --git a/packages/extension/vite.sw.config.mts b/packages/extension/vite.sw.config.mts new file mode 100644 index 0000000000000..a383e4b4a50e9 --- /dev/null +++ b/packages/extension/vite.sw.config.mts @@ -0,0 +1,31 @@ +/** + * Copyright (c) Microsoft Corporation. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { resolve } from 'path'; +import { defineConfig } from 'vite'; + +export default defineConfig({ + build: { + lib: { + entry: resolve(__dirname, 'src/background.ts'), + fileName: 'lib/background', + formats: ['es'] + }, + outDir: 'dist', + emptyOutDir: false, + minify: false + } +}); diff --git a/packages/isomorphic/protocolMetainfo.ts b/packages/isomorphic/protocolMetainfo.ts index 13cf120061641..5c56bc4b521c7 100644 --- a/packages/isomorphic/protocolMetainfo.ts +++ b/packages/isomorphic/protocolMetainfo.ts @@ -167,6 +167,7 @@ export const methodMetainfo = new Map([ ['Frame.click', { title: 'Click', slowMo: true, snapshot: true, pause: true, input: true, isAutoWaiting: true, }], ['Frame.content', { title: 'Get content', snapshot: true, pause: true, }], ['Frame.dragAndDrop', { title: 'Drag and drop', slowMo: true, snapshot: true, pause: true, input: true, isAutoWaiting: true, }], + ['Frame.drop', { title: 'Drop files or data onto an element', slowMo: true, snapshot: true, pause: true, input: true, isAutoWaiting: true, }], ['Frame.dblclick', { title: 'Double click', slowMo: true, snapshot: true, pause: true, input: true, isAutoWaiting: true, }], ['Frame.dispatchEvent', { title: 'Dispatch "{type}"', slowMo: true, snapshot: true, pause: true, }], ['Frame.evaluateExpression', { title: 'Evaluate', snapshot: true, pause: true, }], diff --git a/packages/playwright-client/types/types.d.ts b/packages/playwright-client/types/types.d.ts index d4e0fa0d97da9..1ed380005167a 100644 --- a/packages/playwright-client/types/types.d.ts +++ b/packages/playwright-client/types/types.d.ts @@ -13386,6 +13386,97 @@ export interface Locator { trial?: boolean; }): Promise; + /** + * Simulate an external drag-and-drop of files or clipboard-like data onto this locator. + * + * **Details** + * + * Dispatches the native `dragenter`, `dragover`, and `drop` events at the center of the target element with a + * synthetic [DataTransfer] carrying the provided files and/or data entries. Works cross-browser by constructing the + * [DataTransfer] in the page context. + * + * If the target element's `dragover` listener does not call `preventDefault()`, the target is considered to have + * rejected the drop: Playwright dispatches `dragleave` and this method throws. + * + * **Usage** + * + * Drop a file buffer onto an upload area: + * + * ```js + * await page.locator('#dropzone').drop({ + * files: { name: 'note.txt', mimeType: 'text/plain', buffer: Buffer.from('hello') }, + * }); + * ``` + * + * Drop plain text and a URL together: + * + * ```js + * await page.locator('#dropzone').drop({ + * data: { + * 'text/plain': 'hello world', + * 'text/uri-list': 'https://example.com', + * }, + * }); + * ``` + * + * @param payload Data to drop onto the target. Provide `files` (file paths or in-memory buffers), `data` (a mime-type → string map + * for clipboard-like content such as `text/plain`, `text/html`, `text/uri-list`), or both. + * @param options + */ + drop(payload: { + files?: string|Array|{ + /** + * File name + */ + name: string; + + /** + * File type + */ + mimeType: string; + + /** + * File content + */ + buffer: Buffer; + }|Array<{ + /** + * File name + */ + name: string; + + /** + * File type + */ + mimeType: string; + + /** + * File content + */ + buffer: Buffer; + }>; + + data?: { [key: string]: string; }; + }, options?: { + /** + * A point to use relative to the top-left corner of element padding box. If not specified, uses some visible point of + * the element. + */ + position?: { + x: number; + + y: number; + }; + + /** + * Maximum time in milliseconds. Defaults to `0` - no timeout. The default value can be changed via `actionTimeout` + * option in the config, or by using the + * [browserContext.setDefaultTimeout(timeout)](https://playwright.dev/docs/api/class-browsercontext#browser-context-set-default-timeout) + * or [page.setDefaultTimeout(timeout)](https://playwright.dev/docs/api/class-page#page-set-default-timeout) methods. + */ + timeout?: number; + }): Promise; + /** * **NOTE** Always prefer using [Locator](https://playwright.dev/docs/api/class-locator)s and web assertions over * [ElementHandle](https://playwright.dev/docs/api/class-elementhandle)s because latter are inherently racy. diff --git a/packages/playwright-core/src/client/frame.ts b/packages/playwright-core/src/client/frame.ts index c3220dcaa0047..0a03ab019d163 100644 --- a/packages/playwright-core/src/client/frame.ts +++ b/packages/playwright-core/src/client/frame.ts @@ -32,7 +32,7 @@ import { TimeoutSettings } from './timeoutSettings'; import type { LocatorOptions } from './locator'; import type { Page } from './page'; -import type { FilePayload, LifecycleEvent, SelectOption, SelectOptionOptions, StrictOptions, TimeoutOptions, WaitForFunctionOptions } from './types'; +import type { DropPayload, FilePayload, LifecycleEvent, SelectOption, SelectOptionOptions, StrictOptions, TimeoutOptions, WaitForFunctionOptions } from './types'; import type * as structs from '../../types/structs'; import type * as api from '../../types/types'; import type { ByRoleOptions } from '@isomorphic/locatorUtils'; @@ -305,6 +305,24 @@ export class Frame extends ChannelOwner implements api.Fr return await this._channel.dragAndDrop({ source, target, ...options, timeout: this._timeout(options) }); } + async _drop(selector: string, payload: DropPayload, options: Omit & TimeoutOptions = {}) { + let fileParams: { payloads?: channels.FrameDropParams['payloads'], localPaths?: string[], streams?: channels.FrameDropParams['streams'] } = {}; + if (payload.files !== undefined) { + const converted = await convertInputFiles(this._platform, payload.files, this.page().context()); + if (converted.localDirectory || converted.directoryStream) + throw new Error('Dropping a directory is not supported — pass individual files.'); + fileParams = { payloads: converted.payloads, localPaths: converted.localPaths, streams: converted.streams }; + } + const dataArray = payload.data ? Object.entries(payload.data).map(([mimeType, value]) => ({ mimeType, value })) : undefined; + await this._channel.drop({ + selector, + ...fileParams, + data: dataArray, + ...options, + timeout: this._timeout(options), + }); + } + async tap(selector: string, options: channels.FrameTapOptions & TimeoutOptions = {}) { return await this._channel.tap({ selector, ...options, timeout: this._timeout(options) }); } diff --git a/packages/playwright-core/src/client/locator.ts b/packages/playwright-core/src/client/locator.ts index 08ba14ab73c46..9de0f2b29528e 100644 --- a/packages/playwright-core/src/client/locator.ts +++ b/packages/playwright-core/src/client/locator.ts @@ -23,7 +23,7 @@ import { ElementHandle } from './elementHandle'; import { DisposableStub } from './disposable'; import type { Frame } from './frame'; -import type { FilePayload, FrameExpectParams, Rect, SelectOption, SelectOptionOptions, TimeoutOptions } from './types'; +import type { DropPayload, FilePayload, FrameExpectParams, Rect, SelectOption, SelectOptionOptions, TimeoutOptions } from './types'; import type * as structs from '../../types/structs'; import type * as api from '../../types/types'; import type { ByRoleOptions } from '@isomorphic/locatorUtils'; @@ -126,6 +126,10 @@ export class Locator implements api.Locator { }); } + async drop(payload: DropPayload, options: Omit & TimeoutOptions = {}) { + await this._frame._drop(this._selector, payload, { strict: true, ...options }); + } + async evaluate(pageFunction: structs.PageFunctionOn, arg?: Arg, options?: TimeoutOptions): Promise { return await this._withElement(h => h.evaluate(pageFunction, arg), { title: 'Evaluate', timeout: options?.timeout }); } diff --git a/packages/playwright-core/src/client/types.ts b/packages/playwright-core/src/client/types.ts index 5977bb5da62d5..cb90ec932f987 100644 --- a/packages/playwright-core/src/client/types.ts +++ b/packages/playwright-core/src/client/types.ts @@ -35,6 +35,10 @@ export type WaitForFunctionOptions = TimeoutOptions & { polling?: 'raf' | number export type SelectOption = { value?: string, label?: string, index?: number, valueOrLabel?: string }; export type SelectOptionOptions = TimeoutOptions & { force?: boolean }; export type FilePayload = { name: string, mimeType: string, buffer: Buffer }; +export type DropPayload = { + files?: string | FilePayload | string[] | FilePayload[], + data?: { [mimeType: string]: string }, +}; export type StorageState = { cookies: channels.NetworkCookie[], origins: (Omit)[], diff --git a/packages/playwright-core/src/protocol/validator.ts b/packages/playwright-core/src/protocol/validator.ts index 366fe4411f092..90946df8e119d 100644 --- a/packages/playwright-core/src/protocol/validator.ts +++ b/packages/playwright-core/src/protocol/validator.ts @@ -1713,6 +1713,24 @@ scheme.FrameDragAndDropParams = tObject({ steps: tOptional(tInt), }); scheme.FrameDragAndDropResult = tOptional(tObject({})); +scheme.FrameDropParams = tObject({ + selector: tString, + strict: tOptional(tBoolean), + position: tOptional(tType('Point')), + payloads: tOptional(tArray(tObject({ + name: tString, + mimeType: tOptional(tString), + buffer: tBinary, + }))), + localPaths: tOptional(tArray(tString)), + streams: tOptional(tArray(tChannel(['WritableStream']))), + data: tOptional(tArray(tObject({ + mimeType: tString, + value: tString, + }))), + timeout: tFloat, +}); +scheme.FrameDropResult = tOptional(tObject({})); scheme.FrameDblclickParams = tObject({ selector: tString, strict: tOptional(tBoolean), diff --git a/packages/playwright-core/src/server/dispatchers/frameDispatcher.ts b/packages/playwright-core/src/server/dispatchers/frameDispatcher.ts index 0d476718559c4..b20a19587ad31 100644 --- a/packages/playwright-core/src/server/dispatchers/frameDispatcher.ts +++ b/packages/playwright-core/src/server/dispatchers/frameDispatcher.ts @@ -156,6 +156,10 @@ export class FrameDispatcher extends Dispatcher { + return await this._frame.drop(progress, params.selector, params, params); + } + async tap(params: channels.FrameTapParams, progress: Progress): Promise { return await this._frame.tap(progress, params.selector, params); } diff --git a/packages/playwright-core/src/server/dom.ts b/packages/playwright-core/src/server/dom.ts index 347dc1bec977b..c0870c965ba88 100644 --- a/packages/playwright-core/src/server/dom.ts +++ b/packages/playwright-core/src/server/dom.ts @@ -15,6 +15,9 @@ */ import fs from 'fs'; +import path from 'path'; + +import mime from 'mime'; import { isUnderTest } from '@utils/debug'; import * as js from './javascript'; @@ -35,7 +38,7 @@ export type InputFilesItems = { localDirectory?: string }; -type ActionName = 'click' | 'hover' | 'dblclick' | 'tap' | 'move and up' | 'move and down'; +type ActionName = 'click' | 'hover' | 'dblclick' | 'tap' | 'move and up' | 'move and down' | 'drop'; type PerformActionResult = 'error:notvisible' | 'error:notconnected' | 'error:notinviewport' | 'error:optionsnotfound' | 'error:optionnotenabled' | { missingState: ElementState } | { hitTargetDescription: string } | 'done'; export class NonRecoverableDOMError extends Error { @@ -657,6 +660,73 @@ export class ElementHandle extends js.JSHandle { return assertDone(throwRetargetableDOMError(result)); } + async _drop(progress: Progress, inputFileItems: InputFilesItems, data: { mimeType: string, value: string }[], options: types.PointerActionWaitOptions): Promise<'error:notconnected' | 'done'> { + const { filePayloads, localPaths } = inputFileItems; + let payloads: { name: string, mimeType: string, buffer: string, lastModifiedMs?: number }[]; + if (localPaths && !filePayloads) { + // Co-located server/browser: read files into buffers so File objects can be + // constructed in page context. + payloads = await Promise.all(localPaths.map(async p => ({ + name: path.basename(p), + mimeType: mime.getType(p) || 'application/octet-stream', + buffer: (await fs.promises.readFile(p)).toString('base64'), + lastModifiedMs: (await fs.promises.stat(p)).mtimeMs, + }))); + } else { + payloads = (filePayloads ?? []).map(p => ({ + name: p.name, + mimeType: p.mimeType || 'application/octet-stream', + buffer: p.buffer, + lastModifiedMs: p.lastModifiedMs, + })); + } + return this._retryPointerAction(progress, 'drop', false /* waitForEnabled */, async (progress, point) => { + // Firefox strips files from DataTransfer objects that cross the isolated-world + // boundary into the page's main world. Adopt the element to main context and + // construct the DataTransfer + dispatch events there. + const mainContext = await progress.race(this._frame.mainContext()); + const handle = this._context === mainContext ? this : await progress.race(this._page.delegate.adoptElementHandle(this, mainContext)); + const disposeHandle = handle !== this; + try { + const result = await progress.race(handle.evaluate((node: Node, { payloads, data, point }) => { + if (!node.isConnected || node.nodeType !== 1 /* ELEMENT_NODE */) + return 'error:notconnected' as const; + const element = node as Element; + const dt = new DataTransfer(); + for (const p of payloads) { + const bytes = Uint8Array.from(atob(p.buffer), c => c.charCodeAt(0)); + const file = new File([bytes], p.name, { type: p.mimeType, lastModified: p.lastModifiedMs }); + dt.items.add(file); + } + for (const entry of data) + dt.setData(entry.mimeType, entry.value); + const makeEvent = (type: string) => new DragEvent(type, { + bubbles: true, + cancelable: true, + composed: true, + clientX: point.x, + clientY: point.y, + dataTransfer: dt, + }); + element.dispatchEvent(makeEvent('dragenter')); + const over = makeEvent('dragover'); + element.dispatchEvent(over); + if (!over.defaultPrevented) { + element.dispatchEvent(makeEvent('dragleave')); + return 'not-accepted' as const; + } + element.dispatchEvent(makeEvent('drop')); + return 'accepted' as const; + }, { payloads, data, point })); + if (result === 'not-accepted') + throw new NonRecoverableDOMError('Drop target did not accept the drop — its dragover handler did not call preventDefault()'); + } finally { + if (disposeHandle) + handle.dispose(); + } + }, { ...options, waitAfter: 'disabled' }); + } + async _setInputFiles(progress: Progress, items: InputFilesItems): Promise<'error:notconnected' | 'done'> { const { filePayloads, localPaths, localDirectory } = items; const multiple = filePayloads && filePayloads.length > 1 || localPaths && localPaths.length > 1; diff --git a/packages/playwright-core/src/server/frames.ts b/packages/playwright-core/src/server/frames.ts index aef7d3d1d2117..90fafe6282f98 100644 --- a/packages/playwright-core/src/server/frames.ts +++ b/packages/playwright-core/src/server/frames.ts @@ -1411,6 +1411,16 @@ export class Frame extends SdkObject { return dom.assertDone(await this._retryWithProgressIfNotConnected(progress, selector, params, (progress, handle) => handle._setInputFiles(progress, inputFileItems))); } + async drop(progress: Progress, selector: string, params: Omit, options: types.PointerActionWaitOptions): Promise { + const hasFiles = !!(params.payloads?.length || params.localPaths?.length || params.streams?.length); + const hasData = !!params.data?.length; + if (!hasFiles && !hasData) + throw new Error('At least one of "files" or "data" must be provided.'); + const inputFileItems = hasFiles ? await progress.race(prepareFilesForUpload(this, params)) : { filePayloads: undefined, localPaths: undefined }; + const data = params.data ?? []; + dom.assertDone(await this._retryWithProgressIfNotConnected(progress, selector, options, (progress, handle) => handle._drop(progress, inputFileItems, data, options))); + } + async type(progress: Progress, selector: string, text: string, options: { delay?: number, noAutoWaiting?: boolean } & types.StrictOptions) { return dom.assertDone(await this._retryWithProgressIfNotConnected(progress, selector, options, (progress, handle) => handle._type(progress, text, options))); } diff --git a/packages/playwright-core/src/tools/backend/browserBackend.ts b/packages/playwright-core/src/tools/backend/browserBackend.ts index 12b18ad2971e2..46f9a3c153677 100644 --- a/packages/playwright-core/src/tools/backend/browserBackend.ts +++ b/packages/playwright-core/src/tools/backend/browserBackend.ts @@ -51,19 +51,20 @@ export class BrowserBackend implements ServerBackend { } async callTool(name: string, rawArguments: mcpServer.CallToolRequest['params']['arguments'] & { _meta?: Record } = {}): Promise { + const json = !!rawArguments._meta?.json; + const formatError = (message: string): mcpServer.CallToolResult => ({ + content: [{ type: 'text' as const, text: json ? JSON.stringify({ isError: true, error: message }, null, 2) : `### Error\n${message}` }], + isError: true, + }); const tool = this._tools.find(tool => tool.schema.name === name)!; - if (!tool) { - return { - content: [{ type: 'text' as const, text: `### Error\nTool "${name}" not found` }], - isError: true, - }; - } + if (!tool) + return formatError(`Tool "${name}" not found`); // eslint-disable-next-line no-restricted-syntax const parsedArguments = tool.schema.inputSchema.parse(rawArguments) as any; const cwd = rawArguments._meta?.cwd; const raw = !!rawArguments._meta?.raw; const context = this._context!; - const response = new Response(context, name, parsedArguments, { relativeTo: cwd, raw }); + const response = new Response(context, name, parsedArguments, { relativeTo: cwd, raw, json }); context.setRunningTool(name); let responseObject: mcpServer.CallToolResult; try { @@ -71,10 +72,7 @@ export class BrowserBackend implements ServerBackend { responseObject = await response.serialize(); this._sessionLog?.logResponse(name, parsedArguments, responseObject); } catch (error: any) { - return { - content: [{ type: 'text' as const, text: `### Error\n${String(error)}` }], - isError: true, - }; + return formatError(String(error)); } finally { context.setRunningTool(undefined); } diff --git a/packages/playwright-core/src/tools/backend/response.ts b/packages/playwright-core/src/tools/backend/response.ts index 6a92c68132596..e010fbc551616 100644 --- a/packages/playwright-core/src/tools/backend/response.ts +++ b/packages/playwright-core/src/tools/backend/response.ts @@ -57,13 +57,15 @@ export class Response { private _clientWorkspace: string; private _imageResults: { data: Buffer, imageType: 'png' | 'jpeg' }[] = []; private _raw: boolean; + private _json: boolean; - constructor(context: Context, toolName: string, toolArgs: Record, options?: { relativeTo?: string, raw?: boolean }) { + constructor(context: Context, toolName: string, toolArgs: Record, options?: { relativeTo?: string, raw?: boolean, json?: boolean }) { this._context = context; this.toolName = toolName; this.toolArgs = toolArgs; this._clientWorkspace = options?.relativeTo ?? context.options.cwd; - this._raw = options?.raw ?? false; + this._json = options?.json ?? false; + this._raw = this._json || (options?.raw ?? false); } private _computRelativeTo(fileName: string): string { @@ -157,26 +159,47 @@ export class Response { const rawSections = ['Error', 'Result', 'Snapshot'] as const; const sections = this._raw ? allSections.filter(section => rawSections.includes(section.title as typeof rawSections[number])) : allSections; - const text: string[] = []; - for (const section of sections) { - if (!section.content.length) - continue; - if (!this._raw) { - text.push(`### ${section.title}`); - if (section.codeframe) - text.push(`\`\`\`${section.codeframe}`); - text.push(...section.content); - if (section.codeframe) - text.push('```'); - } else { - text.push(...section.content); + let serializedText: string; + if (this._json) { + const payload: Record = {}; + const isError = sections.some(section => section.isError); + if (isError) + payload.isError = true; + for (const section of sections) { + if (!section.content.length) + continue; + const key = section.title.toLowerCase(); + if (key === 'snapshot') { + const match = section.content[0]?.match(/^- \[Snapshot\]\(([^)]+)\)$/); + payload.snapshot = match ? { file: match[1] } : section.content.join('\n'); + } else { + payload[key] = section.content.join('\n'); + } + } + serializedText = JSON.stringify(payload, null, 2); + } else { + const text: string[] = []; + for (const section of sections) { + if (!section.content.length) + continue; + if (!this._raw) { + text.push(`### ${section.title}`); + if (section.codeframe) + text.push(`\`\`\`${section.codeframe}`); + text.push(...section.content); + if (section.codeframe) + text.push('```'); + } else { + text.push(...section.content); + } } + serializedText = text.join('\n'); } const content: (TextContent | ImageContent)[] = [ { type: 'text', - text: sanitizeUnicode(this._redactSecrets(text.join('\n'))), + text: sanitizeUnicode(this._redactSecrets(serializedText)), } ]; diff --git a/packages/playwright-core/src/tools/cli-client/DEPS.list b/packages/playwright-core/src/tools/cli-client/DEPS.list index 6d5be4ad810d9..086f0c0e74918 100644 --- a/packages/playwright-core/src/tools/cli-client/DEPS.list +++ b/packages/playwright-core/src/tools/cli-client/DEPS.list @@ -4,9 +4,14 @@ ../../serverRegistry.ts ./channelSessions.ts ./minimist.ts +./output.ts ./session.ts ./registry.ts +[output.ts] +"strict" +./channelSessions.ts + [channelSessions.ts] "strict" diff --git a/packages/playwright-core/src/tools/cli-client/output.ts b/packages/playwright-core/src/tools/cli-client/output.ts new file mode 100644 index 0000000000000..39d49d24e6f68 --- /dev/null +++ b/packages/playwright-core/src/tools/cli-client/output.ts @@ -0,0 +1,375 @@ +/** + * Copyright (c) Microsoft Corporation. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +/* eslint-disable no-console */ +/* eslint-disable no-restricted-properties */ + +import path from 'path'; + +import { remoteDebuggingHint } from './channelSessions'; + +import type { ChannelSession } from './channelSessions'; +import type { BrowserStatus } from '../../serverRegistry'; + +export type ListedBrowser = { + name: string; + workspace: string; + status: 'open' | 'closed'; + browserType: string | undefined; + userDataDir: string | null; + headed: boolean | undefined; + persistent: boolean; + attached: boolean; + compatible: boolean; + version: string; +}; + +export type ListData = { + all: boolean; + browsers: ListedBrowser[]; + servers?: BrowserStatus[]; + channelSessions?: ChannelSession[]; +}; + +export interface Output { + readonly json: boolean; + + version(v: string): void; + help(text: string): void; + + errorUnknownCommand(name: string | undefined, globalHelp: string): never; + errorUnknownOption(opts: string[], commandHelp: string): never; + errorAttachConflict(): never; + errorBrowserNotOpenForTool(session: string): never; + + list(data: ListData): void; + closeAll(sessions: string[]): void; + deleteData(session: string, result: { existed: boolean, deletedUserDataDir: boolean }): void; + killAll(pids: number[]): void; + open(session: string, pid: number | undefined, toolResult: string): void; + attach(session: string, pid: number | undefined, endpoint: string | undefined, toolResult: string): void; + close(session: string, wasOpen: boolean): void; + installed(): void; + show(session: string, pid: number | undefined): void; + toolResult(text: string): void; + + installStdio(): 'inherit' | 'ignore'; +} + +export class TextOutput implements Output { + readonly json = false; + + version(v: string): void { + console.log(v); + } + + help(text: string): void { + console.log(text); + } + + errorUnknownCommand(name: string | undefined, globalHelp: string): never { + console.error(`Unknown command: ${name}\n`); + console.log(globalHelp); + return process.exit(1); + } + + errorUnknownOption(opts: string[], commandHelp: string): never { + console.error(`Unknown option${opts.length > 1 ? 's' : ''}: ${opts.map(f => `--${f}`).join(', ')}`); + console.log(''); + console.log(commandHelp); + return process.exit(1); + } + + errorAttachConflict(): never { + console.error(`Error: cannot use target name with --cdp, --endpoint, or --extension`); + return process.exit(1); + } + + errorBrowserNotOpenForTool(session: string): never { + console.log(`The browser '${session}' is not open, please run open first`); + console.log(''); + console.log(` playwright-cli${session !== 'default' ? ` -s=${session}` : ''} open [params]`); + return process.exit(1); + } + + list({ all, browsers, servers, channelSessions }: ListData): void { + const byWorkspace = new Map(); + for (const browser of browsers) { + let list = byWorkspace.get(browser.workspace); + if (!list) { + list = []; + byWorkspace.set(browser.workspace, list); + } + list.push(browser); + } + + let count = 0; + for (const [workspaceKey, list] of byWorkspace) { + if (count === 0) + console.log('### Browsers'); + if (all) + console.log(`${path.relative(process.cwd(), workspaceKey) || '/'}:`); + for (const browser of list) + console.log(renderBrowser(browser)); + count += list.length; + } + + if (!all) { + if (!count) + console.log(' (no browsers)'); + return; + } + + if (servers?.length) { + if (count) + console.log(''); + console.log('### Browser servers available for attach'); + const serversByWorkspace = new Map(); + for (const server of servers) { + let list = serversByWorkspace.get(server.workspaceDir ?? ''); + if (!list) { + list = []; + serversByWorkspace.set(server.workspaceDir ?? '', list); + } + list.push(server); + } + for (const [workspaceKey, list] of serversByWorkspace) { + if (workspaceKey) + console.log(`${path.relative(process.cwd(), workspaceKey) || '/'}:`); + for (const server of list) + console.log(renderServer(server)); + } + count += servers.length; + } + + if (!count) + console.log(' (no browsers)'); + + if (channelSessions?.length) { + console.log(''); + console.log('### Browsers available to attach via CDP'); + for (const session of channelSessions) + console.log(renderChannelSession(session)); + } + } + + closeAll(_sessions: string[]): void { + // Text mode is intentionally silent, matching historical behavior. + } + + deleteData(session: string, result: { existed: boolean, deletedUserDataDir: boolean }): void { + if (!result.existed) { + console.log(`No user data found for browser '${session}'.`); + return; + } + if (result.deletedUserDataDir) + console.log(`Deleted user data for browser '${session}'.`); + } + + killAll(pids: number[]): void { + for (const pid of pids) + console.log(`Killed daemon process ${pid}`); + if (pids.length === 0) + console.log('No daemon processes found.'); + else + console.log(`Killed ${pids.length} daemon process${pids.length === 1 ? '' : 'es'}.`); + } + + open(session: string, pid: number | undefined, toolResult: string): void { + console.log(`### Browser \`${session}\` opened with pid ${pid}.`); + if (toolResult) + console.log(toolResult); + } + + attach(session: string, pid: number | undefined, endpoint: string | undefined, toolResult: string): void { + if (endpoint) { + console.log(`### Session \`${session}\` created, attached to \`${endpoint}\`.`); + console.log(`Run commands with: playwright-cli --session=${session} `); + } else { + console.log(`### Browser \`${session}\` opened with pid ${pid}.`); + } + if (toolResult) + console.log(toolResult); + } + + close(session: string, wasOpen: boolean): void { + if (!wasOpen) { + console.log(`Browser '${session}' is not open.`); + return; + } + console.log(`Browser '${session}' closed\n`); + } + + installed(): void { + // The spawned install subprocess handles its own output. + } + + show(_session: string, pid: number | undefined): void { + if (process.env.PLAYWRIGHT_PRINT_DASHBOARD_PID_FOR_TEST) + console.log(`### Dashboard opened with pid ${pid}.`); + } + + toolResult(text: string): void { + console.log(text); + } + + installStdio(): 'inherit' | 'ignore' { + return 'inherit'; + } +} + +export class JsonOutput implements Output { + readonly json = true; + + version(v: string): void { + this._emit({ version: v }); + } + + help(text: string): void { + this._emit({ help: text }); + } + + errorUnknownCommand(name: string | undefined, _globalHelp: string): never { + this._emit({ isError: true, error: `Unknown command: ${name}` }); + return process.exit(1); + } + + errorUnknownOption(opts: string[], _commandHelp: string): never { + this._emit({ isError: true, error: `Unknown option${opts.length > 1 ? 's' : ''}: ${opts.map(f => `--${f}`).join(', ')}` }); + return process.exit(1); + } + + errorAttachConflict(): never { + this._emit({ isError: true, error: `cannot use target name with --cdp, --endpoint, or --extension` }); + return process.exit(1); + } + + errorBrowserNotOpenForTool(session: string): never { + this._emit({ isError: true, error: `The browser '${session}' is not open, please run open first` }); + return process.exit(1); + } + + list({ all, browsers, servers, channelSessions }: ListData): void { + const payload: Record = { browsers }; + if (all) { + payload.servers = servers ?? []; + payload.channelSessions = channelSessions ?? []; + } + this._emit(payload); + } + + closeAll(sessions: string[]): void { + this._emit({ closed: sessions }); + } + + deleteData(session: string, result: { existed: boolean, deletedUserDataDir: boolean }): void { + this._emit({ session, deleted: result.existed }); + } + + killAll(pids: number[]): void { + this._emit({ killed: pids.length, pids }); + } + + open(session: string, pid: number | undefined, toolResult: string): void { + this._emit({ session, pid, result: parseJsonText(toolResult) }); + } + + attach(session: string, pid: number | undefined, endpoint: string | undefined, toolResult: string): void { + this._emit({ + session, + pid, + ...(endpoint ? { endpoint } : {}), + result: parseJsonText(toolResult), + }); + } + + close(session: string, wasOpen: boolean): void { + this._emit({ session, status: wasOpen ? 'closed' : 'not-open' }); + } + + installed(): void { + this._emit({ installed: true }); + } + + show(session: string, pid: number | undefined): void { + this._emit({ session, pid }); + } + + toolResult(text: string): void { + // Daemon already returns pretty-printed JSON, write through verbatim. + console.log(text); + } + + installStdio(): 'inherit' | 'ignore' { + return 'ignore'; + } + + private _emit(value: unknown): void { + console.log(JSON.stringify(value, null, 2)); + } +} + +function parseJsonText(text: string): unknown { + try { + return JSON.parse(text); + } catch { + return text; + } +} + +function renderBrowser(browser: ListedBrowser): string { + const lines = [`- ${browser.name}:`]; + lines.push(` - status: ${browser.status}`); + if (browser.status === 'open' && !browser.compatible) + lines.push(` - version: v${browser.version} [incompatible please re-open]`); + if (browser.browserType) + lines.push(` - browser-type: ${browser.browserType}${browser.attached ? ' (attached)' : ''}`); + if (!browser.attached) { + if (browser.userDataDir === null) + lines.push(` - user-data-dir: `); + else + lines.push(` - user-data-dir: ${browser.userDataDir}`); + if (browser.headed !== undefined) + lines.push(` - headed: ${browser.headed}`); + } + return lines.join('\n'); +} + +function renderServer(server: BrowserStatus): string { + const lines = [`- browser "${server.title}":`]; + lines.push(` - browser: ${server.browser.browserName}`); + lines.push(` - version: v${server.playwrightVersion}`); + lines.push(` - status: ${server.canConnect ? 'open' : 'closed'}`); + if (server.browser.userDataDir) + lines.push(` - data-dir: ${server.browser.userDataDir}`); + else + lines.push(` - data-dir: `); + lines.push(` - run \`playwright-cli attach "${server.title}"\` to attach`); + return lines.join('\n'); +} + +function renderChannelSession(session: ChannelSession): string { + const lines = [`- ${session.channel}:`]; + lines.push(` - data-dir: ${session.userDataDir}`); + if (session.endpoint) { + lines.push(` - endpoint: ${session.endpoint}`); + lines.push(` - run \`playwright-cli attach --cdp=${session.channel}\` to attach`); + } else { + lines.push(` - status: remote debugging not enabled`); + lines.push(` - ${remoteDebuggingHint(session.channel)}`); + } + return lines.join('\n'); +} diff --git a/packages/playwright-core/src/tools/cli-client/program.ts b/packages/playwright-core/src/tools/cli-client/program.ts index 3a28394d06a0c..5a7dabd03ad7c 100644 --- a/packages/playwright-core/src/tools/cli-client/program.ts +++ b/packages/playwright-core/src/tools/cli-client/program.ts @@ -14,27 +14,28 @@ * limitations under the License. */ -/* eslint-disable no-console */ /* eslint-disable no-restricted-properties */ import { execSync, spawn } from 'child_process'; import crypto from 'crypto'; import os from 'os'; -import path from 'path'; -import { listChannelSessions, remoteDebuggingHint } from './channelSessions'; + +import { listChannelSessions } from './channelSessions'; +import { JsonOutput, TextOutput } from './output'; import { clientKey, createClientInfo, explicitSessionName, Registry, resolveSessionName } from './registry'; -import { Session, renderResolvedConfig } from './session'; +import { Session } from './session'; import { libPath } from '../../package'; import { serverRegistry } from '../../serverRegistry'; import { minimist } from './minimist'; +import type { ListData, ListedBrowser, Output } from './output'; import type { ClientInfo, SessionFile } from './registry'; -import type { BrowserStatus } from '../../serverRegistry'; import type { MinimistArgs } from './minimist'; type GlobalOptions = { help?: boolean; + json?: boolean; raw?: boolean; session?: string; version?: boolean; @@ -56,6 +57,7 @@ type OpenOptions = { }; const globalOptions: (keyof (GlobalOptions & OpenOptions & AttachOptions))[] = [ + 'json', 'raw', 'session', ]; @@ -63,6 +65,7 @@ const globalOptions: (keyof (GlobalOptions & OpenOptions & AttachOptions))[] = [ const booleanOptions: (keyof (GlobalOptions & OpenOptions & AttachOptions & { all?: boolean }))[] = [ 'all', 'help', + 'json', 'raw', 'version', ]; @@ -80,72 +83,71 @@ export async function program(options?: { embedderVersion?: string}) { delete args.s; } + const output: Output = args.json ? new JsonOutput() : new TextOutput(); const commandName = args._?.[0]; if (args.version || args.v) { - console.log(options?.embedderVersion ?? clientInfo.version); + output.version(options?.embedderVersion ?? clientInfo.version); process.exit(0); } const command = commandName && help.commands[commandName]; if (args.help || args.h) { - if (command) { - console.log(command.help); - } else { - console.log('playwright-cli - run playwright mcp commands from terminal\n'); - console.log(help.global); - } + output.help(command ? command.help : 'playwright-cli - run playwright mcp commands from terminal\n\n' + help.global); process.exit(0); } - if (!command) { - console.error(`Unknown command: ${commandName}\n`); - console.log(help.global); - process.exit(1); - } + if (!command) + output.errorUnknownCommand(commandName, help.global); - validateFlags(args, command); + validateFlags(args, command, output); const registry = await Registry.load(); const sessionName = resolveSessionName(args.session as string); switch (commandName) { case 'list': { - await listSessions(registry, clientInfo, !!args.all); + const data = await collectList(registry, clientInfo, !!args.all); + output.list(data); return; } case 'close-all': { const entries = registry.entries(clientInfo); - for (const entry of entries) - await new Session(entry).stop(true); + const closed: string[] = []; + for (const entry of entries) { + await new Session(entry).stop(); + closed.push(entry.config.name); + } + output.closeAll(closed); return; } case 'delete-data': { const entry = registry.entry(clientInfo, sessionName); if (!entry) { - console.log(`No user data found for browser '${sessionName}'.`); + output.deleteData(sessionName, { existed: false, deletedUserDataDir: false }); return; } - await new Session(entry).deleteData(); + const result = await new Session(entry).deleteData(); + output.deleteData(sessionName, result); return; } case 'kill-all': { - await killAllDaemons(); + const pids = await killAllDaemons(); + output.killAll(pids); return; } case 'open': { - await startSession(sessionName, registry, clientInfo, args, 'open'); + const { pid } = await startSession(sessionName, registry, clientInfo, args, 'open'); const newEntry = await registry.loadEntry(clientInfo, sessionName); const params = args._.slice(1); - await runInSession(newEntry, clientInfo, { _: ['goto', ...(params.length ? params : ['about:blank'])] }); + const toolText = await runInSession(newEntry, clientInfo, { _: ['goto', ...(params.length ? params : ['about:blank'])] }, output); + output.open(sessionName, pid, toolText); return; } case 'attach': { const attachTarget = args._[1] as string | undefined; - if (attachTarget && (args.cdp || args.endpoint || args.extension)) { - console.error(`Error: cannot use target name with --cdp, --endpoint, or --extension`); - process.exit(1); - } + if (attachTarget && (args.cdp || args.endpoint || args.extension)) + output.errorAttachConflict(); if (attachTarget) args.endpoint = attachTarget; if (typeof args.extension === 'string') { @@ -154,25 +156,25 @@ export async function program(options?: { embedderVersion?: string}) { } const attachSessionName = explicitSessionName(args.session as string) ?? attachTarget ?? sessionName; args.session = attachSessionName; - await startSession(attachSessionName, registry, clientInfo, args, 'attach'); + const { pid, endpoint } = await startSession(attachSessionName, registry, clientInfo, args, 'attach'); const newEntry = await registry.loadEntry(clientInfo, attachSessionName); - await runInSession(newEntry, clientInfo, { _: ['snapshot'], filename: '' }); + const toolText = await runInSession(newEntry, clientInfo, { _: ['snapshot'], filename: '' }, output); + output.attach(attachSessionName, pid, endpoint, toolText); return; } - case 'close': + case 'close': { const closeEntry = registry.entry(clientInfo, sessionName); - const session = closeEntry ? new Session(closeEntry) : undefined; - if (!session || !await session.canConnect()) { - console.log(`Browser '${sessionName}' is not open.`); - return; - } - await session.stop(); + const { wasOpen } = closeEntry ? await new Session(closeEntry).stop() : { wasOpen: false }; + output.close(sessionName, wasOpen); return; + } case 'install': - await runInitWorkspace(args); + await runInitWorkspace(args, output); + output.installed(); return; case 'install-browser': await installBrowser(); + output.installed(); return; case 'show': { const daemonScript = libPath('entry', 'dashboardApp.js'); @@ -195,19 +197,15 @@ export async function program(options?: { embedderVersion?: string}) { return; } child.unref(); - if (process.env.PLAYWRIGHT_PRINT_DASHBOARD_PID_FOR_TEST) - console.log(`### Dashboard opened with pid ${child.pid}.`); + output.show(sessionName, child.pid); return; } default: { const entry = registry.entry(clientInfo, sessionName); - if (!entry) { - console.log(`The browser '${sessionName}' is not open, please run open first`); - console.log(''); - console.log(` playwright-cli${sessionName !== 'default' ? ` -s=${sessionName}` : ''} open [params]`); - process.exit(1); - } - await runInSession(entry, clientInfo, args); + if (!entry) + output.errorBrowserNotOpenForTool(sessionName); + const text = await runInSession(entry, clientInfo, args, output); + output.toolResult(text); } } } @@ -215,25 +213,25 @@ export async function program(options?: { embedderVersion?: string}) { async function startSession(sessionName: string, registry: Registry, clientInfo: ClientInfo, args: MinimistArgs, mode: 'open' | 'attach') { const entry = registry.entry(clientInfo, sessionName); if (entry) - await new Session(entry).stop(true); - await Session.startDaemon(clientInfo, args, mode); + await new Session(entry).stop(); + return await Session.startDaemon(clientInfo, args, mode); } -async function runInSession(entry: SessionFile, clientInfo: ClientInfo, args: MinimistArgs) { +async function runInSession(entry: SessionFile, clientInfo: ClientInfo, args: MinimistArgs, output: Output): Promise { const raw = !!args.raw; for (const globalOption of globalOptions) delete args[globalOption]; const session = new Session(entry); - const result = await session.run(clientInfo, args, { raw }); - console.log(result.text); + const result = await session.run(clientInfo, args, { raw, json: output.json }); + return result.text; } -async function runInitWorkspace(args: MinimistArgs) { +async function runInitWorkspace(args: MinimistArgs, output: Output) { const cliPath = libPath('entry', 'cliDaemon.js'); const daemonArgs: string[] = [cliPath, '--init-workspace', ...(args.skills ? ['--init-skills', String(args.skills)] : [])]; await new Promise((resolve, reject) => { const child = spawn(process.execPath, daemonArgs, { - stdio: 'inherit', + stdio: output.installStdio(), cwd: process.cwd(), }); child.on('close', code => { @@ -256,11 +254,11 @@ async function installBrowser() { const daemonProcessPatterns = ['run-mcp-server', 'run-cli-server', 'cli-daemon', 'cliDaemon.js', 'dashboardApp.js']; -async function killAllDaemons(): Promise { +async function killAllDaemons(): Promise { const platform = os.platform(); const pidFilterEnv = process.env.PLAYWRIGHT_KILL_ALL_PID_FILTER_FOR_TEST; const pidFilter = pidFilterEnv ? new Set(pidFilterEnv.split(',').map(p => parseInt(p, 10)).filter(n => !isNaN(n))) : undefined; - let killed = 0; + const killed: number[] = []; try { if (platform === 'win32') { @@ -279,8 +277,7 @@ async function killAllDaemons(): Promise { .map(line => line.trim()) .filter(line => /^\d+$/.test(line)); for (const pid of pids) - console.log(`Killed daemon process ${pid}`); - killed = pids.length; + killed.push(parseInt(pid, 10)); } else { const result = execSync('ps auxww', { encoding: 'utf-8' }); const lines = result.split('\n'); @@ -294,8 +291,7 @@ async function killAllDaemons(): Promise { continue; try { process.kill(numericPid, 'SIGKILL'); - console.log(`Killed daemon process ${pid}`); - killed++; + killed.push(numericPid); } catch { // Process may have already exited } @@ -306,124 +302,49 @@ async function killAllDaemons(): Promise { } catch (e) { // Silently handle errors - no processes to kill is fine } - - if (killed === 0) - console.log('No daemon processes found.'); - else if (killed > 0) - console.log(`Killed ${killed} daemon process${killed === 1 ? '' : 'es'}.`); + return killed; } -async function listSessions(registry: Registry, clientInfo: ClientInfo, all: boolean): Promise { - let count = 0; - const runningSessions = new Set(); +async function collectList(registry: Registry, clientInfo: ClientInfo, all: boolean): Promise { + const browsers: ListedBrowser[] = []; const entries = registry.entryMap(); const key = clientKey(clientInfo); for (const [workspaceKey, list] of entries) { if (!all && workspaceKey !== key) continue; - if (count === 0) - console.log('### Browsers'); - count += await gcAndPrintSessions(clientInfo, list.map(entry => new Session(entry)), all ? `${path.relative(process.cwd(), workspaceKey) || '/'}:` : undefined, runningSessions); - } - - // Filter out server entries that already have an attached session. - const serverEntries = await serverRegistry.list(); - if (serverEntries.size) { - if (count) - console.log(''); - console.log('### Browser servers available for attach'); - } - for (const [workspaceKey, list] of serverEntries) - count += await gcAndPrintBrowserSessions(workspaceKey, list); - - if (!count) - console.log(' (no browsers)'); - - const channelSessions = await listChannelSessions(); - if (channelSessions.length) { - console.log(''); - console.log('### Browsers available to attach via CDP'); - for (const session of channelSessions) { - const text: string[] = []; - text.push(`- ${session.channel}:`); - text.push(` - data-dir: ${session.userDataDir}`); - if (session.endpoint) { - text.push(` - endpoint: ${session.endpoint}`); - text.push(` - run \`playwright-cli attach --cdp=${session.channel}\` to attach`); - } else { - text.push(` - status: remote debugging not enabled`); - text.push(` - ${remoteDebuggingHint(session.channel)}`); - } - console.log(text.join('\n')); - } - } -} - -async function gcAndPrintSessions(clientInfo: ClientInfo, sessions: Session[], header?: string, runningSessions?: Set) { - const running: Session[] = []; - const stopped: Session[] = []; - - for (const session of sessions) { - const canConnect = await session.canConnect(); - if (canConnect) { - running.push(session); - runningSessions?.add(session.name); - } else { - if (session.config.cli.persistent) - stopped.push(session); - else + for (const entry of list) { + const session = new Session(entry); + const canConnect = await session.canConnect(); + if (!canConnect && !session.config.cli.persistent) { await session.deleteSessionConfig(); + continue; + } + const config = session.config; + const channel = config.browser?.launchOptions.channel ?? config.browser?.browserName; + browsers.push({ + name: session.name, + workspace: workspaceKey, + status: canConnect ? 'open' : 'closed', + browserType: channel, + userDataDir: config.browser?.userDataDir ?? null, + headed: config.browser ? !config.browser.launchOptions.headless : undefined, + persistent: !!config.cli.persistent, + attached: !!config.attached, + compatible: session.isCompatible(clientInfo), + version: config.version, + }); } } - if (header && (running.length || stopped.length)) - console.log(header); - - for (const session of running) - console.log(await renderSessionStatus(clientInfo, session)); - for (const session of stopped) - console.log(await renderSessionStatus(clientInfo, session)); + if (!all) + return { all, browsers }; - return running.length + stopped.length; -} - -async function gcAndPrintBrowserSessions(workspace: string, list: BrowserStatus[]): Promise { - if (!list.length) - return 0; - - if (workspace) - console.log(`${path.relative(process.cwd(), workspace) || '/'}:`); - - for (const descriptor of list) { - const text: string[] = []; - text.push(`- browser "${descriptor.title}":`); - text.push(` - browser: ${descriptor.browser.browserName}`); - text.push(` - version: v${descriptor.playwrightVersion}`); - text.push(` - status: ${descriptor.canConnect ? 'open' : 'closed'}`); - if (descriptor.browser.userDataDir) - text.push(` - data-dir: ${descriptor.browser.userDataDir}`); - else - text.push(` - data-dir: `); - text.push(` - run \`playwright-cli attach "${descriptor.title}"\` to attach`); - console.log(text.join('\n')); - } - return list.length; -} - -async function renderSessionStatus(clientInfo: ClientInfo, session: Session) { - const text: string[] = []; - const config = session.config; - const canConnect = await session.canConnect(); - text.push(`- ${session.name}:`); - text.push(` - status: ${canConnect ? 'open' : 'closed'}`); - if (canConnect && !session.isCompatible(clientInfo)) - text.push(` - version: v${config.version} [incompatible please re-open]`); - if (config.browser) - text.push(...renderResolvedConfig(config)); - return text.join('\n'); + const serverEntries = await serverRegistry.list(); + const servers = [...serverEntries.values()].flat(); + return { all, browsers, servers, channelSessions: await listChannelSessions() }; } -function validateFlags(args: MinimistArgs, command: { flags: Record, help: string }) { +function validateFlags(args: MinimistArgs, command: { flags: Record, help: string }, output: Output) { const unknownFlags: string[] = []; for (const key of Object.keys(args)) { if (key === '_') @@ -433,12 +354,8 @@ function validateFlags(args: MinimistArgs, command: { flags: Record 1 ? 's' : ''}: ${unknownFlags.map(f => `--${f}`).join(', ')}`); - console.log(''); - console.log(command.help); - process.exit(1); - } + if (unknownFlags.length) + output.errorUnknownOption(unknownFlags, command.help); } export function calculateSha1(buffer: Buffer | string): string { diff --git a/packages/playwright-core/src/tools/cli-client/session.ts b/packages/playwright-core/src/tools/cli-client/session.ts index 28ecb5886bf0a..3aed7eee2e7e1 100644 --- a/packages/playwright-core/src/tools/cli-client/session.ts +++ b/packages/playwright-core/src/tools/cli-client/session.ts @@ -14,8 +14,6 @@ * limitations under the License. */ -/* eslint-disable no-console */ - import { spawn } from 'child_process'; import fs from 'fs'; @@ -44,57 +42,50 @@ export class Session { return compareSemver(clientInfo.version, this.config.version) >= 0; } - async run(clientInfo: ClientInfo, args: MinimistArgs, options?: { raw?: boolean }): Promise<{ text: string }> { + async run(clientInfo: ClientInfo, args: MinimistArgs, options?: { raw?: boolean, json?: boolean }): Promise<{ text: string }> { if (!this.isCompatible(clientInfo)) throw new Error(`Client is v${clientInfo.version}, session '${this.name}' is v${this.config.version}. Run\n\n playwright-cli${this.name !== 'default' ? ` -s=${this.name}` : ''} open\n\nto restart the browser session.`); const { socket } = await this._connect(); if (!socket) throw new Error(`Browser '${this.name}' is not open. Run\n\n playwright-cli${this.name !== 'default' ? ` -s=${this.name}` : ''} open\n\nto start the browser session.`); - return await SocketConnectionClient.sendAndClose(socket, 'run', { args, cwd: process.cwd(), raw: options?.raw }); + return await SocketConnectionClient.sendAndClose(socket, 'run', { args, cwd: process.cwd(), raw: options?.raw, json: options?.json }); } - async stop(quiet: boolean = false): Promise { - if (!await this.canConnect()) { - if (!quiet) - console.log(`Browser '${this.name}' is not open.`); - return; - } - + async stop(): Promise<{ wasOpen: boolean }> { + if (!await this.canConnect()) + return { wasOpen: false }; await this._stopDaemon(); - if (!quiet) - console.log(`Browser '${this.name}' closed\n`); + return { wasOpen: true }; } - async deleteData() { - await this.stop(true); + async deleteData(): Promise<{ existed: boolean, deletedUserDataDir: boolean }> { + await this.stop(); const dataDirs = await fs.promises.readdir(this._sessionFile.daemonDir).catch(() => []); const matchingEntries = dataDirs.filter(file => file === `${this.name}.session` || file.startsWith(`ud-${this.name}-`)); - if (matchingEntries.length === 0) { - console.log(`No user data found for browser '${this.name}'.`); - return; - } + if (matchingEntries.length === 0) + return { existed: false, deletedUserDataDir: false }; + let deletedUserDataDir = false; for (const entry of matchingEntries) { const userDataDir = path.resolve(this._sessionFile.daemonDir, entry); for (let i = 0; i < 5; i++) { try { await fs.promises.rm(userDataDir, { recursive: true }); if (entry.startsWith('ud-')) - console.log(`Deleted user data for browser '${this.name}'.`); + deletedUserDataDir = true; break; } catch (e: any) { - if (e.code === 'ENOENT') { - console.log(`No user data found for browser '${this.name}'.`); + if (e.code === 'ENOENT') break; - } await new Promise(resolve => setTimeout(resolve, 1000)); if (i === 4) throw e; } } } + return { existed: true, deletedUserDataDir }; } private async _connect(): Promise<{ socket?: net.Socket, error?: Error }> { @@ -120,7 +111,7 @@ export class Session { return false; } - static async startDaemon(clientInfo: ClientInfo, cliArgs: MinimistArgs, mode: 'open' | 'attach') { + static async startDaemon(clientInfo: ClientInfo, cliArgs: MinimistArgs, mode: 'open' | 'attach'): Promise<{ pid: number | undefined, sessionName: string, endpoint: string | undefined }> { await fs.promises.mkdir(clientInfo.daemonProfilesDir, { recursive: true }); const cliPath = libPath('entry', 'cliDaemon.js'); @@ -201,20 +192,13 @@ export class Session { child.stdout!.destroy(); child.unref(); - if (cliArgs['endpoint']) { - console.log(`### Session \`${sessionName}\` created, attached to \`${cliArgs['endpoint']}\`.`); - console.log(`Run commands with: playwright-cli --session=${sessionName} `); - } else { - console.log(`### Browser \`${sessionName}\` opened with pid ${child.pid}.`); - } + return { pid: child.pid, sessionName, endpoint: cliArgs.endpoint as string | undefined }; } private async _stopDaemon(): Promise { - const { socket, error: socketError } = await this._connect(); - if (!socket) { - console.log(`Browser '${this.name}' is not open.${socketError ? ' Error: ' + socketError.message : ''}`); + const { socket } = await this._connect(); + if (!socket) return; - } let error: Error | undefined; await SocketConnectionClient.sendAndClose(socket, 'stop', {}).catch(e => error = e); @@ -227,24 +211,6 @@ export class Session { } } -export function renderResolvedConfig(config: SessionConfig) { - const channel = config.browser.launchOptions.channel ?? config.browser.browserName; - const lines = []; - const isAttached = config.attached; - if (channel) - lines.push(` - browser-type: ${channel}${isAttached ? ' (attached)' : ''}`); - - if (!isAttached) { - if (!config.cli.persistent) - lines.push(` - user-data-dir: `); - else - lines.push(` - user-data-dir: ${config.browser.userDataDir}`); - lines.push(` - headed: ${!config.browser.launchOptions.headless}`); - } - - return lines; -} - class SocketConnectionClient { private _connection: SocketConnection; private _nextMessageId = 1; diff --git a/packages/playwright-core/src/tools/cli-client/skill/SKILL.md b/packages/playwright-core/src/tools/cli-client/skill/SKILL.md index c38e2ed07c0ec..8a22932e73d1a 100644 --- a/packages/playwright-core/src/tools/cli-client/skill/SKILL.md +++ b/packages/playwright-core/src/tools/cli-client/skill/SKILL.md @@ -185,6 +185,11 @@ TOKEN=$(playwright-cli --raw cookie-get session_id) playwright-cli --raw localstorage-get theme ``` +For structured output wrapping every reply as JSON, pass --json +```bash +playwright-cli list --json +``` + ## Open parameters ```bash # Use specific browser when creating session diff --git a/packages/playwright-core/src/tools/cli-daemon/daemon.ts b/packages/playwright-core/src/tools/cli-daemon/daemon.ts index 8cd65b1945345..84c61546106ee 100644 --- a/packages/playwright-core/src/tools/cli-daemon/daemon.ts +++ b/packages/playwright-core/src/tools/cli-daemon/daemon.ts @@ -90,7 +90,7 @@ export async function startCliDaemonServer( await sendAck(); } else if (method === 'run') { const { toolName, toolParams } = parseCliCommand(params.args); - toolParams._meta = { cwd: params.cwd, raw: params.raw }; + toolParams._meta = { cwd: params.cwd, raw: params.raw || params.json, json: !!params.json }; const response = await backend.callTool(toolName, toolParams); await connection.send({ id, result: formatResult(response) }); } else { diff --git a/packages/playwright-core/src/tools/cli-daemon/helpGenerator.ts b/packages/playwright-core/src/tools/cli-daemon/helpGenerator.ts index c167a2e115d6a..08634ff9244b4 100644 --- a/packages/playwright-core/src/tools/cli-daemon/helpGenerator.ts +++ b/packages/playwright-core/src/tools/cli-daemon/helpGenerator.ts @@ -104,6 +104,7 @@ export function generateHelp() { lines.push('\nGlobal options:'); lines.push(formatWithGap(' --help [command]', 'print help')); + lines.push(formatWithGap(' --json', 'output response as JSON')); lines.push(formatWithGap(' --raw', 'output only the result value, without status and code')); lines.push(formatWithGap(' --version', 'print version')); diff --git a/packages/playwright-core/src/tools/dashboard/dashboardApp.ts b/packages/playwright-core/src/tools/dashboard/dashboardApp.ts index 3fa98c7ba1779..fb1feba7e6553 100644 --- a/packages/playwright-core/src/tools/dashboard/dashboardApp.ts +++ b/packages/playwright-core/src/tools/dashboard/dashboardApp.ts @@ -35,7 +35,6 @@ type RevealOptions = { sessionName?: string; workspaceDir?: string }; async function startDashboardServer(options: { port?: number; host?: string; reveal?: RevealOptions } = {}): Promise<{ url: string; reveal: (options: RevealOptions) => void }> { const httpServer = new HttpServer(); const dashboardDir = libPath('vite', 'dashboard'); - const artifacts = new Map(); const connections = new Set(); let currentReveal: RevealOptions = options.reveal ?? {}; @@ -43,28 +42,13 @@ async function startDashboardServer(options: { port?: number; host?: string; rev httpServer.createWebSocket(() => { let connection: DashboardConnection; // eslint-disable-next-line prefer-const - connection = new DashboardConnection(() => connections.delete(connection), artifacts); + connection = new DashboardConnection(() => connections.delete(connection)); if (currentReveal.sessionName) connection.revealSession(currentReveal.sessionName, currentReveal.workspaceDir); connections.add(connection); return connection; }, 'ws'); - httpServer.routePrefix('/artifact/', (request: http.IncomingMessage, response: http.ServerResponse) => { - const pathname = new URL(request.url!, `http://${request.headers.host}`).pathname; - const id = decodeURIComponent(pathname.substring('/artifact/'.length)); - const artifactPath = artifacts.get(id); - if (!artifactPath) { - response.statusCode = 404; - response.end(); - return true; - } - // we're not deleting the artifact on purpose, so that the user can restart the download from the omnibox - return httpServer.serveFile(request, response, artifactPath, { - 'Content-Disposition': `attachment; filename="${path.basename(artifactPath)}"`, - }); - }); - httpServer.routePrefix('/', (request: http.IncomingMessage, response: http.ServerResponse) => { const pathname = new URL(request.url!, `http://${request.headers.host}`).pathname; const filePath = pathname === '/' ? 'index.html' : pathname.substring(1); @@ -95,20 +79,20 @@ async function innerOpenDashboardApp(initialReveal: RevealOptions): Promise<{ pa async function launchApp(appName: string) { const channel = findChromiumChannelBestEffort('javascript'); - const debugPort = parseInt(process.env.PLAYWRIGHT_DASHBOARD_DEBUG_PORT!, 10) || undefined; const context = await playwright.chromium.launchPersistentContext('', { ignoreDefaultArgs: ['--enable-automation'], channel, - headless: debugPort !== undefined, + headless: !!process.env.PW_DASHBOARD_APP_BIND_TITLE, args: [ '--app=data:text/html,', '--test-type=', `--window-size=1280,800`, `--window-position=100,100`, - ...(debugPort !== undefined ? [`--remote-debugging-port=${debugPort}`] : []), ], viewport: null, }); + if (process.env.PW_DASHBOARD_APP_BIND_TITLE) + await context.browser()?.bind(process.env.PW_DASHBOARD_APP_BIND_TITLE, { workspaceDir: process.cwd() }); const [page] = context.pages(); // Chromium on macOS opens a new tab when clicking on the dock icon. diff --git a/packages/playwright-core/src/tools/dashboard/dashboardController.ts b/packages/playwright-core/src/tools/dashboard/dashboardController.ts index 0a5740ab5eeaf..3f47aae614f55 100644 --- a/packages/playwright-core/src/tools/dashboard/dashboardController.ts +++ b/packages/playwright-core/src/tools/dashboard/dashboardController.ts @@ -23,7 +23,6 @@ import { eventsHelper } from '@utils/eventsHelper'; import { connectToBrowserAcrossVersions } from '../utils/connect'; import { serverRegistry } from '../../serverRegistry'; import { createClientInfo } from '../cli-client/registry'; -import { resolveDashboardDownloadsDir } from './dashboardDownloads'; import type * as api from '../../..'; import type { Transport } from '@utils/httpServer'; @@ -53,13 +52,12 @@ export class DashboardConnection implements Transport { private _visible = true; private _pendingReveal: { sessionName: string; workspaceDir?: string } | undefined; - _artifactDir: string; - _artifacts: Map; + _recordingDir: string; + _streams = new Map(); - constructor(onclose: () => void, artifacts: Map) { + constructor(onclose: () => void) { this._onclose = onclose; - this._artifactDir = fs.mkdtempSync(path.join(os.tmpdir(), 'playwright-dashboard-')); - this._artifacts = artifacts; + this._recordingDir = fs.mkdtempSync(path.join(os.tmpdir(), 'playwright-recordings-')); } onconnect() { @@ -78,6 +76,13 @@ export class DashboardConnection implements Transport { this._serverRegistryDispose = undefined; this._attachedBrowser?.dispose(); this._attachedBrowser = undefined; + for (const stream of this._streams.values()) { + void stream.handle.close() + .catch(() => {}) + .then(() => fs.promises.unlink(stream.path)) + .catch(() => {}); + } + this._streams.clear(); for (const slot of this._browsers.values()) slot.listeners.forEach(d => d.dispose()); this._browsers.clear(); @@ -163,21 +168,33 @@ export class DashboardConnection implements Transport { this._pushTabs(); } - async saveAndReveal(params: { id: string }) { - const source = this._artifacts.get(params.id); - if (!source) - throw new Error(`Unknown artifact id: ${params.id}`); - this._artifacts.delete(params.id); - const destination = path.join(resolveDashboardDownloadsDir(), path.basename(source)); - // Rename avoids copying bytes when source and destination are on the same - // filesystem. On Linux, tmpdir and Downloads may be on different devices — - // rename fails with EXDEV, so fall back to copy + unlink. - await fs.promises.rename(source, destination).catch(async () => { - await fs.promises.copyFile(source, destination); - await fs.promises.unlink(source).catch(() => {}); - }); - if (!process.env.PLAYWRIGHT_DASHBOARD_DOWNLOADS_DIR_FOR_TEST) - revealInFinder(destination); + async reveal(params: { path: string }) { + switch (os.platform()) { + case 'darwin': + execFile('open', ['-R', params.path]); + break; + case 'win32': + execFile('explorer', ['/select,', params.path]); + break; + case 'linux': + execFile('xdg-open', [path.dirname(params.path)]); + break; + } + } + + async readStream(params: { streamId: string }): Promise<{ data: string; eof: boolean }> { + const stream = this._streams.get(params.streamId); + if (!stream) + throw new Error(`Unknown stream: ${params.streamId}`); + const buffer = Buffer.alloc(256 * 1024); + const { bytesRead } = await stream.handle.read(buffer, 0, buffer.length); + if (bytesRead === 0) { + this._streams.delete(params.streamId); + await stream.handle.close().catch(() => {}); + await fs.promises.unlink(stream.path).catch(() => {}); + return { data: '', eof: true }; + } + return { data: buffer.subarray(0, bytesRead).toString('base64'), eof: false }; } visible(): boolean { @@ -364,6 +381,7 @@ class AttachedBrowser { get browserGuid(): string { return this._slot.guid; } get contextGuid(): string { return this._slot.contextGuid; } private get _context(): api.BrowserContext { return this._slot.context; } + private get _descriptor(): BrowserDescriptor { return this._slot.descriptor; } async init() { this._contextListeners.push( @@ -482,32 +500,31 @@ class AttachedBrowser { const page = this._selectedPage; if (!page) return; - this._recordingPath = path.join(this._owner._artifactDir, `playwright-recording-${Date.now()}.webm`); + const artifactsDir = this._descriptor.browser.launchOptions.artifactsDir ?? this._owner._recordingDir; + this._recordingPath = path.join(artifactsDir, `recording-${Date.now()}.webm`); if (this._screencastRunning) await this._restartScreencast(page); } - async stopRecording(): Promise<{ id: string }> { + async stopRecording(): Promise<{ streamId: string }> { const p = this._recordingPath; if (!p) throw new Error('No recording in progress'); this._recordingPath = null; if (this._selectedPage && this._screencastRunning) await this._restartScreencast(this._selectedPage); - const id = crypto.randomUUID(); - this._owner._artifacts.set(id, p); - return { id }; + const handle = await fs.promises.open(p, 'r'); + const streamId = crypto.randomUUID(); + this._owner._streams.set(streamId, { handle, path: p }); + return { streamId }; } - async screenshot(): Promise<{ id: string }> { + async screenshot(): Promise { const page = this._selectedPage; if (!page) throw new Error('No page selected'); - const absolutePath = path.join(this._owner._artifactDir, `playwright-screenshot-${Date.now()}.png`); - await page.screenshot({ type: 'png', path: absolutePath }); - const id = crypto.randomUUID(); - this._owner._artifacts.set(id, absolutePath); - return { id }; + const buffer = await page.screenshot({ type: 'png' }); + return buffer.toString('base64'); } private async _selectPage(page: api.Page) { @@ -578,20 +595,6 @@ function pageId(p: api.Page): string { return (p as any)._guid; } -function revealInFinder(target: string) { - switch (os.platform()) { - case 'darwin': - execFile('open', ['-R', target]); - break; - case 'win32': - execFile('explorer', ['/select,', target]); - break; - case 'linux': - execFile('xdg-open', [path.dirname(target)]); - break; - } -} - async function faviconUrl(page: api.Page): Promise { const url = page.evaluate(async () => { const response = await fetch(document.querySelector('link[rel~="icon"]')?.href ?? '/favicon.ico'); diff --git a/packages/playwright-core/src/tools/dashboard/dashboardDownloads.ts b/packages/playwright-core/src/tools/dashboard/dashboardDownloads.ts deleted file mode 100644 index 15d7aee63ea6a..0000000000000 --- a/packages/playwright-core/src/tools/dashboard/dashboardDownloads.ts +++ /dev/null @@ -1,40 +0,0 @@ -/** - * Copyright (c) Microsoft Corporation. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import fs from 'fs'; -import os from 'os'; -import path from 'path'; - -// Returns the directory the dashboard should drop screenshots and recordings -// into when running in "app" mode. We use ~/Downloads, which is the physical -// on-disk path on macOS and Windows (both display a localized name via -// Finder/Explorer) and on English-locale Linux. Non-English Linux locales -// localize the directory (~/Téléchargements, ~/下载, ...) via XDG's -// user-dirs.dirs; we don't parse that file and fall back to a tmpdir subdir -// there. If that becomes a pain point, read ~/.config/user-dirs.dirs. -export function resolveDashboardDownloadsDir(): string { - const override = process.env.PLAYWRIGHT_DASHBOARD_DOWNLOADS_DIR_FOR_TEST; - if (override) { - fs.mkdirSync(override, { recursive: true }); - return override; - } - const primary = path.join(os.homedir(), 'Downloads'); - if (fs.existsSync(primary)) - return primary; - const fallback = path.join(os.tmpdir(), 'playwright-dashboard-downloads'); - fs.mkdirSync(fallback, { recursive: true }); - return fallback; -} diff --git a/packages/playwright-core/types/types.d.ts b/packages/playwright-core/types/types.d.ts index d4e0fa0d97da9..1ed380005167a 100644 --- a/packages/playwright-core/types/types.d.ts +++ b/packages/playwright-core/types/types.d.ts @@ -13386,6 +13386,97 @@ export interface Locator { trial?: boolean; }): Promise; + /** + * Simulate an external drag-and-drop of files or clipboard-like data onto this locator. + * + * **Details** + * + * Dispatches the native `dragenter`, `dragover`, and `drop` events at the center of the target element with a + * synthetic [DataTransfer] carrying the provided files and/or data entries. Works cross-browser by constructing the + * [DataTransfer] in the page context. + * + * If the target element's `dragover` listener does not call `preventDefault()`, the target is considered to have + * rejected the drop: Playwright dispatches `dragleave` and this method throws. + * + * **Usage** + * + * Drop a file buffer onto an upload area: + * + * ```js + * await page.locator('#dropzone').drop({ + * files: { name: 'note.txt', mimeType: 'text/plain', buffer: Buffer.from('hello') }, + * }); + * ``` + * + * Drop plain text and a URL together: + * + * ```js + * await page.locator('#dropzone').drop({ + * data: { + * 'text/plain': 'hello world', + * 'text/uri-list': 'https://example.com', + * }, + * }); + * ``` + * + * @param payload Data to drop onto the target. Provide `files` (file paths or in-memory buffers), `data` (a mime-type → string map + * for clipboard-like content such as `text/plain`, `text/html`, `text/uri-list`), or both. + * @param options + */ + drop(payload: { + files?: string|Array|{ + /** + * File name + */ + name: string; + + /** + * File type + */ + mimeType: string; + + /** + * File content + */ + buffer: Buffer; + }|Array<{ + /** + * File name + */ + name: string; + + /** + * File type + */ + mimeType: string; + + /** + * File content + */ + buffer: Buffer; + }>; + + data?: { [key: string]: string; }; + }, options?: { + /** + * A point to use relative to the top-left corner of element padding box. If not specified, uses some visible point of + * the element. + */ + position?: { + x: number; + + y: number; + }; + + /** + * Maximum time in milliseconds. Defaults to `0` - no timeout. The default value can be changed via `actionTimeout` + * option in the config, or by using the + * [browserContext.setDefaultTimeout(timeout)](https://playwright.dev/docs/api/class-browsercontext#browser-context-set-default-timeout) + * or [page.setDefaultTimeout(timeout)](https://playwright.dev/docs/api/class-page#page-set-default-timeout) methods. + */ + timeout?: number; + }): Promise; + /** * **NOTE** Always prefer using [Locator](https://playwright.dev/docs/api/class-locator)s and web assertions over * [ElementHandle](https://playwright.dev/docs/api/class-elementhandle)s because latter are inherently racy. diff --git a/packages/protocol/src/channels.d.ts b/packages/protocol/src/channels.d.ts index 41566f45a2fbb..9f802a5191a61 100644 --- a/packages/protocol/src/channels.d.ts +++ b/packages/protocol/src/channels.d.ts @@ -2813,6 +2813,7 @@ export interface FrameChannel extends FrameEventTarget, Channel { click(params: FrameClickParams, progress?: Progress): Promise; content(params?: FrameContentParams, progress?: Progress): Promise; dragAndDrop(params: FrameDragAndDropParams, progress?: Progress): Promise; + drop(params: FrameDropParams, progress?: Progress): Promise; dblclick(params: FrameDblclickParams, progress?: Progress): Promise; dispatchEvent(params: FrameDispatchEventParams, progress?: Progress): Promise; evaluateExpression(params: FrameEvaluateExpressionParams, progress?: Progress): Promise; @@ -3016,6 +3017,39 @@ export type FrameDragAndDropOptions = { steps?: number, }; export type FrameDragAndDropResult = void; +export type FrameDropParams = { + selector: string, + strict?: boolean, + position?: Point, + payloads?: { + name: string, + mimeType?: string, + buffer: Binary, + }[], + localPaths?: string[], + streams?: WritableStreamChannel[], + data?: { + mimeType: string, + value: string, + }[], + timeout: number, +}; +export type FrameDropOptions = { + strict?: boolean, + position?: Point, + payloads?: { + name: string, + mimeType?: string, + buffer: Binary, + }[], + localPaths?: string[], + streams?: WritableStreamChannel[], + data?: { + mimeType: string, + value: string, + }[], +}; +export type FrameDropResult = void; export type FrameDblclickParams = { selector: string, strict?: boolean, diff --git a/packages/protocol/src/protocol.yml b/packages/protocol/src/protocol.yml index 949843f8f879e..fb5f4e6f485a7 100644 --- a/packages/protocol/src/protocol.yml +++ b/packages/protocol/src/protocol.yml @@ -2421,6 +2421,42 @@ Frame: input: true isAutoWaiting: true + drop: + title: Drop files or data onto an element + parameters: + selector: string + strict: boolean? + position: Point? + # Only one of payloads, localPaths and streams may be present. + payloads: + type: array? + items: + type: object + properties: + name: string + mimeType: string? + buffer: binary + localPaths: + type: array? + items: string + streams: + type: array? + items: WritableStream + data: + type: array? + items: + type: object + properties: + mimeType: string + value: string + timeout: float + flags: + slowMo: true + snapshot: true + pause: true + input: true + isAutoWaiting: true + dblclick: title: Double click parameters: diff --git a/tests/extension/cli.spec.ts b/tests/extension/cli.spec.ts new file mode 100644 index 0000000000000..a103776135484 --- /dev/null +++ b/tests/extension/cli.spec.ts @@ -0,0 +1,60 @@ +/** + * Copyright (c) Microsoft Corporation. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import fs from 'fs/promises'; +import { test, expect, extensionId } from './extension-fixtures'; + +test('attach --extension', async ({ browserWithExtension, cli, server }, testInfo) => { + const browserContext = await browserWithExtension.launch(); + + // Write config file with userDataDir + const configPath = testInfo.outputPath('cli-config.json'); + await fs.writeFile(configPath, JSON.stringify({ + browser: { + userDataDir: browserWithExtension.userDataDir, + } + }, null, 2)); + + const confirmationPagePromise = browserContext.waitForEvent('page', page => { + return page.url().startsWith(`chrome-extension://${extensionId}/connect.html`); + }); + + // Start the CLI command in the background + const cliPromise = cli('attach', '--extension', `--config=cli-config.json`); + + // Wait for the confirmation page to appear + const confirmationPage = await confirmationPagePromise; + + // Click the Connect button + await confirmationPage.locator('.tab-item', { hasText: 'Welcome' }).getByRole('button', { name: 'Connect' }).click(); + + { + // Wait for the CLI command to complete + const { output } = await cliPromise; + // Verify the output + expect(output).toContain(`### Page`); + expect(output).toContain(`- Page URL: chrome-extension://${extensionId}/connect.html?`); + expect(output).toContain(`- Page Title: Welcome`); + } + + { + const { output } = await cli('goto', server.HELLO_WORLD); + // Verify the output + expect(output).toContain(`### Page`); + expect(output).toContain(`- Page URL: ${server.HELLO_WORLD}`); + expect(output).toContain(`- Page Title: Title`); + } +}); diff --git a/tests/extension/extension-fixtures.ts b/tests/extension/extension-fixtures.ts new file mode 100644 index 0000000000000..5ff605939e860 --- /dev/null +++ b/tests/extension/extension-fixtures.ts @@ -0,0 +1,227 @@ +/** + * Copyright (c) Microsoft Corporation. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import fs from 'fs/promises'; +import os from 'os'; +import path from 'path'; +import { chromium } from 'playwright'; +import { spawn } from 'child_process'; +import { test as base, expect } from '../mcp/fixtures'; + +import type { Client } from '@modelcontextprotocol/sdk/client/index.js'; +import type { BrowserContext } from 'playwright'; +import type { StartClient } from '../mcp/fixtures'; + +export type BrowserWithExtension = { + userDataDir: string; + launch: (mode?: 'disable-extension') => Promise; +}; + +export type CliResult = { + output: string; + error: string; +}; + +export type ExtensionTestOptions = { + protocolVersion: 1 | 2; +}; + +export type TestFixtures = { + browserWithExtension: BrowserWithExtension, + pathToExtension: string, + startExtensionClient: (env?: Record) => Promise<{ browserContext: BrowserContext, client: Client }>, + cli: (...args: string[]) => Promise; +}; + +type WorkerFixtures = { + _protocolEnv: void; +}; + +export const extensionId = 'mmlmfjhmonkocbjadbfplnigmagldckm'; + +export const test = base.extend({ + protocolVersion: [2, { option: true, scope: 'worker' }], + + _protocolEnv: [async ({ protocolVersion }, use) => { + // Default is 1. + if (protocolVersion === 2) + process.env.PLAYWRIGHT_EXTENSION_PROTOCOL = '2'; + await use(); + }, { auto: true, scope: 'worker' }], + + pathToExtension: async ({}, use, testInfo) => { + const extensionDir = testInfo.outputPath('extension'); + const srcDir = path.resolve(__dirname, '../../packages/extension/dist'); + await fs.cp(srcDir, extensionDir, { recursive: true }); + await use(extensionDir); + }, + + browserWithExtension: async ({ mcpBrowser, pathToExtension }, use, testInfo) => { + // The flags no longer work in Chrome since + // https://chromium.googlesource.com/chromium/src/+/290ed8046692651ce76088914750cb659b65fb17%5E%21/chrome/browser/extensions/extension_service.cc?pli=1# + test.skip('chromium' !== mcpBrowser, '--load-extension is not supported for official builds of Chromium'); + + let browserContext: BrowserContext | undefined; + const userDataDir = testInfo.outputPath('extension-user-data-dir'); + await use({ + userDataDir, + launch: async (mode?: 'disable-extension') => { + browserContext = await chromium.launchPersistentContext(userDataDir, { + channel: mcpBrowser, + // Opening the browser singleton only works in headed. + headless: false, + // Automation disables singleton browser process behavior, which is necessary for the extension. + ignoreDefaultArgs: ['--enable-automation'], + args: mode === 'disable-extension' ? [] : [ + `--disable-extensions-except=${pathToExtension}`, + `--load-extension=${pathToExtension}`, + ], + }); + + // for manifest v3: + let [serviceWorker] = browserContext.serviceWorkers(); + if (!serviceWorker) + serviceWorker = await browserContext.waitForEvent('serviceworker'); + + return browserContext; + } + }); + await browserContext?.close(); + + // Free up disk space. + await fs.rm(userDataDir, { recursive: true, force: true }).catch(() => {}); + }, + + startExtensionClient: async ({ browserWithExtension, startClient }, use) => { + await use(async (env?: Record) => { + const browserContext = await browserWithExtension.launch(); + const client = await startWithExtensionFlag(browserWithExtension, startClient, env); + return { browserContext, client }; + }); + }, + + cli: async ({ mcpBrowser }, use, testInfo) => { + await use(async (...args: string[]) => { + return await runCli(args, { mcpBrowser, testInfo }); + }); + + // Cleanup sessions + await runCli(['close-all'], { mcpBrowser, testInfo }).catch(() => {}); + + const daemonDir = path.join(testInfo.outputDir, 'daemon'); + await fs.rm(daemonDir, { recursive: true, force: true }).catch(() => {}); + }, +}); + +export { expect }; + +export const testWithOldExtensionVersion = test.extend({ + pathToExtension: async ({ pathToExtension }, use) => { + const manifestPath = path.join(pathToExtension, 'manifest.json'); + const manifest = JSON.parse(await fs.readFile(manifestPath, 'utf8')); + manifest.version = '0.0.1'; + await fs.writeFile(manifestPath, JSON.stringify(manifest, null, 2)); + await use(pathToExtension); + }, +}); + +function cliEnv() { + return { + PLAYWRIGHT_SERVER_REGISTRY: test.info().outputPath('registry'), + PLAYWRIGHT_DAEMON_SESSION_DIR: test.info().outputPath('daemon'), + // Short path because macOS caps unix socket paths at 104 chars; the + // long `project.outputDir` path overflows and causes EADDRINUSE. + PLAYWRIGHT_SOCKETS_DIR: path.join(os.tmpdir(), 'pwmcp-sock', String(test.info().parallelIndex)), + }; +} + +async function runCli( + args: string[], + options: { mcpBrowser?: string, testInfo: any }, +): Promise { + const stepTitle = `cli ${args.join(' ')}`; + + return await test.step(stepTitle, async () => { + const testInfo = options.testInfo; + + // Path to the terminal CLI + const cliPath = require.resolve('../../packages/playwright-core/lib/tools/cli-client/cli.js'); + + return new Promise((resolve, reject) => { + let stdout = ''; + let stderr = ''; + + const childProcess = spawn(process.execPath, [cliPath, ...args], { + cwd: testInfo.outputPath(), + env: { + ...process.env, + ...cliEnv(), + PLAYWRIGHT_MCP_BROWSER: options.mcpBrowser, + PLAYWRIGHT_MCP_HEADLESS: 'false', + }, + detached: true, + }); + + childProcess.stdout?.on('data', data => { + stdout += data.toString(); + }); + + childProcess.stderr?.on('data', data => { + if (process.env.PWMCP_DEBUG) + process.stderr.write(data); + stderr += data.toString(); + }); + + childProcess.on('close', async code => { + await testInfo.attach(stepTitle, { body: stdout, contentType: 'text/plain' }); + resolve({ + output: stdout.trim(), + error: stderr.trim(), + }); + }); + + childProcess.on('error', reject); + }); + }); +} + +export async function startWithExtensionFlag(browserWithExtension: BrowserWithExtension, startClient: StartClient, env?: Record): Promise { + const { client } = await startClient({ + args: [`--extension`], + env, + config: { + browser: { + userDataDir: browserWithExtension.userDataDir, + } + }, + }); + return client; +} + +export async function connectAndNavigate( + browserContext: BrowserContext, + client: Client, + url: string, + tabTitle: RegExp | string = 'Welcome', +): Promise>> { + const confirmationPagePromise = browserContext.waitForEvent('page', page => + page.url().startsWith(`chrome-extension://${extensionId}/connect.html`) + ); + const navigatePromise = client.callTool({ name: 'browser_navigate', arguments: { url } }); + const selectorPage = await confirmationPagePromise; + await selectorPage.locator('.tab-item', { hasText: tabTitle }).getByRole('button', { name: 'Connect' }).click(); + return await navigatePromise; +} diff --git a/tests/extension/extension.spec.ts b/tests/extension/extension.spec.ts new file mode 100644 index 0000000000000..230386bc9af1d --- /dev/null +++ b/tests/extension/extension.spec.ts @@ -0,0 +1,343 @@ +/** + * Copyright (c) Microsoft Corporation. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import fs from 'fs/promises'; +import { test, testWithOldExtensionVersion, expect, extensionId, startWithExtensionFlag } from './extension-fixtures'; + +test(`navigate with extension`, async ({ startExtensionClient, server }) => { + const { browserContext, client } = await startExtensionClient(); + + const confirmationPagePromise = browserContext.waitForEvent('page', page => { + return page.url().startsWith(`chrome-extension://${extensionId}/connect.html`); + }); + + const navigateResponse = client.callTool({ + name: 'browser_navigate', + arguments: { url: server.HELLO_WORLD }, + }); + + const selectorPage = await confirmationPagePromise; + await selectorPage.locator('.tab-item', { hasText: 'Welcome' }).getByRole('button', { name: 'Connect' }).click(); + + expect(await navigateResponse).toHaveResponse({ + snapshot: expect.stringContaining(`- generic [active] [ref=e1]: Hello, world!`), + }); +}); + +test(`connect.html protocolVersion search param matches fixture option`, async ({ startExtensionClient, server, protocolVersion }) => { + const { browserContext, client } = await startExtensionClient(); + + const confirmationPagePromise = browserContext.waitForEvent('page', page => { + return page.url().startsWith(`chrome-extension://${extensionId}/connect.html`); + }); + + client.callTool({ + name: 'browser_navigate', + arguments: { url: server.HELLO_WORLD }, + }).catch(() => {}); + + const selectorPage = await confirmationPagePromise; + const url = new URL(selectorPage.url()); + expect(url.searchParams.get('protocolVersion')).toBe(String(protocolVersion)); +}); + +test(`protocolVersion defaults to 1`, async ({ startExtensionClient, server, protocolVersion }) => { + // test.fail(true, 'Server default is currently 2; this test guards the expected default of 1'); + const saved = process.env.PLAYWRIGHT_EXTENSION_PROTOCOL; + delete process.env.PLAYWRIGHT_EXTENSION_PROTOCOL; + + const { browserContext, client } = await startExtensionClient(); + + const confirmationPagePromise = browserContext.waitForEvent('page', page => { + return page.url().startsWith(`chrome-extension://${extensionId}/connect.html`); + }); + + client.callTool({ + name: 'browser_navigate', + arguments: { url: server.HELLO_WORLD }, + }).catch(() => {}); + + const selectorPage = await confirmationPagePromise; + const url = new URL(selectorPage.url()); + expect(url.searchParams.get('protocolVersion')).toBe('1'); + + process.env.PLAYWRIGHT_EXTENSION_PROTOCOL = saved; +}); + +test(`browser_run_code can evaluate in a web worker`, async ({ startExtensionClient, server, protocolVersion }) => { + test.skip(protocolVersion === 1, 'Multi-tab not supported in protocol v1'); + server.setContent('/worker.js', ` + self.onmessage = (e) => self.postMessage('echo:' + e.data); + self.workerName = 'mcp-worker'; + `, 'application/javascript'); + server.setContent('/worker-page', ` + WorkerPage + + + + `, 'text/html'); + + const { browserContext, client } = await startExtensionClient(); + + const confirmationPagePromise = browserContext.waitForEvent('page', page => { + return page.url().startsWith(`chrome-extension://${extensionId}/connect.html`); + }); + + const navigateResponse = client.callTool({ + name: 'browser_navigate', + arguments: { url: server.PREFIX + '/worker-page' }, + }); + + const selectorPage = await confirmationPagePromise; + await selectorPage.locator('.tab-item', { hasText: 'Welcome' }).getByRole('button', { name: 'Connect' }).click(); + + await navigateResponse; + + const runCodeResponse = await client.callTool({ + name: 'browser_run_code', + arguments: { + code: `async (page) => { + const worker = page.workers().length ? page.workers()[0] : await page.waitForEvent('worker'); + return await worker.evaluate(() => self.workerName); + }`, + }, + }); + + expect(runCodeResponse).toHaveResponse({ + result: expect.stringContaining('mcp-worker'), + }); + + // Open a second page with its own worker via browser_tabs new and verify + // that worker eval works in that tab too. This exercises child CDP sessions + // (the worker session) on a non-first tab — the relay must route them to + // the correct tab rather than always falling back to the first one. + server.setContent('/worker2.js', ` + self.workerName = 'mcp-worker-2'; + `, 'application/javascript'); + server.setContent('/worker-page-2', ` + WorkerPage2 + + + + `, 'text/html'); + + await client.callTool({ + name: 'browser_tabs', + arguments: { action: 'new', url: server.PREFIX + '/worker-page-2' }, + }); + + const runCodeResponse2 = await client.callTool({ + name: 'browser_run_code', + arguments: { + code: `async (page) => { + const worker = page.workers().length ? page.workers()[0] : await page.waitForEvent('worker'); + return await worker.evaluate(() => self.workerName); + }`, + }, + }); + + expect(runCodeResponse2).toHaveResponse({ + result: expect.stringContaining('mcp-worker-2'), + }); +}); + +test(`snapshot of an existing page`, async ({ browserWithExtension, startClient, server }) => { + const browserContext = await browserWithExtension.launch(); + + const page = await browserContext.newPage(); + await page.goto(server.HELLO_WORLD); + + // Another empty page. + await browserContext.newPage(); + expect(browserContext.pages()).toHaveLength(3); + + const client = await startWithExtensionFlag(browserWithExtension, startClient); + expect(browserContext.pages()).toHaveLength(3); + + const confirmationPagePromise = browserContext.waitForEvent('page', page => { + return page.url().startsWith(`chrome-extension://${extensionId}/connect.html`); + }); + + const navigateResponse = client.callTool({ + name: 'browser_snapshot', + arguments: { }, + }); + + const selectorPage = await confirmationPagePromise; + expect(browserContext.pages()).toHaveLength(4); + + await selectorPage.locator('.tab-item', { hasText: 'Title' }).getByRole('button', { name: 'Connect' }).click(); + + expect(await navigateResponse).toHaveResponse({ + inlineSnapshot: expect.stringContaining(`- generic [active] [ref=e1]: Hello, world!`), + }); + + expect(browserContext.pages()).toHaveLength(4); +}); + +test(`extension not installed timeout`, async ({ startExtensionClient, server }) => { + const { browserContext, client } = await startExtensionClient({ PWMCP_TEST_CONNECTION_TIMEOUT: '100' }); + + const confirmationPagePromise = browserContext.waitForEvent('page', page => { + return page.url().startsWith(`chrome-extension://${extensionId}/connect.html`); + }); + + expect(await client.callTool({ + name: 'browser_navigate', + arguments: { url: server.HELLO_WORLD }, + })).toHaveResponse({ + error: expect.stringMatching(/Extension connection timeout. Make sure the "Playwright.* is installed\./), + isError: true, + }); + + await confirmationPagePromise; +}); + +testWithOldExtensionVersion(`works with old extension version`, async ({ startExtensionClient, server }) => { + // Prelaunch the browser, so that it is properly closed after the test. + const { browserContext, client } = await startExtensionClient({ PWMCP_TEST_CONNECTION_TIMEOUT: '500' }); + + const confirmationPagePromise = browserContext.waitForEvent('page', page => { + return page.url().startsWith(`chrome-extension://${extensionId}/connect.html`); + }); + + const navigateResponse = client.callTool({ + name: 'browser_navigate', + arguments: { url: server.HELLO_WORLD }, + }); + + const selectorPage = await confirmationPagePromise; + await selectorPage.locator('.tab-item', { hasText: 'Welcome' }).getByRole('button', { name: 'Connect' }).click(); + + expect(await navigateResponse).toHaveResponse({ + snapshot: expect.stringContaining(`- generic [active] [ref=e1]: Hello, world!`), + }); +}); + +test(`extension needs update`, async ({ startExtensionClient, server }) => { + // Prelaunch the browser, so that it is properly closed after the test. + const { browserContext, client } = await startExtensionClient({ PWMCP_TEST_CONNECTION_TIMEOUT: '500', PLAYWRIGHT_EXTENSION_PROTOCOL: '1000' }); + + const confirmationPagePromise = browserContext.waitForEvent('page', page => { + return page.url().startsWith(`chrome-extension://${extensionId}/connect.html`); + }); + + const navigateResponse = client.callTool({ + name: 'browser_navigate', + arguments: { url: server.HELLO_WORLD }, + }); + + const confirmationPage = await confirmationPagePromise; + await expect(confirmationPage.locator('.status-banner')).toContainText(`Playwright MCP version trying to connect requires newer extension version`); + + expect(await navigateResponse).toHaveResponse({ + error: expect.stringContaining('Extension connection timeout.'), + isError: true, + }); +}); + +test(`custom executablePath`, async ({ startClient, server }) => { + const executablePath = test.info().outputPath('echo.sh'); + await fs.writeFile(executablePath, '#!/bin/bash\necho "Custom exec args: $@" > "$(dirname "$0")/output.txt"', { mode: 0o755 }); + + const { client } = await startClient({ + args: [`--extension`], + env: { PWMCP_TEST_CONNECTION_TIMEOUT: '1000' }, + config: { + browser: { + launchOptions: { + executablePath, + }, + } + }, + }); + + const navigateResponse = await client.callTool({ + name: 'browser_navigate', + arguments: { url: server.HELLO_WORLD }, + }); + expect(await navigateResponse).toHaveResponse({ + error: expect.stringContaining('Extension connection timeout.'), + isError: true, + }); + expect(await fs.readFile(test.info().outputPath('output.txt'), 'utf8')).toMatch(new RegExp(`Custom exec args.*chrome-extension://${extensionId}/connect\\.html\\?`)); +}); + +test(`bypass connection dialog with token`, async ({ browserWithExtension, startClient, server }) => { + const browserContext = await browserWithExtension.launch(); + + const page = await browserContext.newPage(); + await page.goto(`chrome-extension://${extensionId}/status.html`); + const token = await page.locator('.auth-token-code').textContent(); + const [, value] = token?.split('=') || []; + + const { client } = await startClient({ + args: [`--extension`], + config: { + browser: { + userDataDir: browserWithExtension.userDataDir, + } + }, + env: { + PLAYWRIGHT_MCP_EXTENSION_TOKEN: value, + }, + }); + + const navigateResponse = await client.callTool({ + name: 'browser_navigate', + arguments: { url: server.HELLO_WORLD }, + }); + + expect(await navigateResponse).toHaveResponse({ + snapshot: expect.stringContaining(`- generic [active] [ref=e1]: Hello, world!`), + }); +}); + +test(`pending connection closed when client disconnects`, async ({ startExtensionClient, server }) => { + const { browserContext, client } = await startExtensionClient(); + + const confirmationPagePromise = browserContext.waitForEvent('page', page => { + return page.url().startsWith(`chrome-extension://${extensionId}/connect.html`); + }); + + client.callTool({ + name: 'browser_navigate', + arguments: { url: server.HELLO_WORLD }, + }).catch(() => {}); + + const selectorPage = await confirmationPagePromise; + // Wait for the tab list to appear so we know the relay connection is established. + await selectorPage.locator('.tab-item').first().waitFor(); + + // Close the MCP client, which tears down the relay WebSocket. + await client.close(); + + await expect(selectorPage.locator('.status-banner')).toContainText('Pending client connection closed.'); + await expect(selectorPage).toHaveTitle('Playwright Extension'); + + // The connect tab should be removed from the Playwright group. + await expect.poll(async () => { + return selectorPage.evaluate(async () => { + const chrome = (window as any).chrome; + const tab = await chrome.tabs.getCurrent(); + return tab?.groupId ?? -1; + }); + }).toBe(-1); +}); diff --git a/tests/extension/playwright.config.ts b/tests/extension/playwright.config.ts new file mode 100644 index 0000000000000..502d6430257f0 --- /dev/null +++ b/tests/extension/playwright.config.ts @@ -0,0 +1,33 @@ +/** + * Copyright (c) Microsoft Corporation. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { defineConfig } from '@playwright/test'; + +import type { TestOptions } from '../mcp/fixtures'; +import type { ExtensionTestOptions } from './extension-fixtures'; + +export default defineConfig({ + testDir: './', + fullyParallel: true, + forbidOnly: !!process.env.CI, + retries: process.env.CI ? 2 : 0, + workers: process.env.CI ? 1 : undefined, + reporter: 'list', + projects: [ + { name: 'chromium', use: { mcpBrowser: 'chromium', protocolVersion: 2 } }, + { name: 'chromium (legacy v1)', use: { mcpBrowser: 'chromium', protocolVersion: 1 } }, + ], +}); diff --git a/tests/extension/tab-grouping.spec.ts b/tests/extension/tab-grouping.spec.ts new file mode 100644 index 0000000000000..62f860f4887c5 --- /dev/null +++ b/tests/extension/tab-grouping.spec.ts @@ -0,0 +1,200 @@ +/** + * Copyright (c) Microsoft Corporation. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { test, expect, extensionId, startWithExtensionFlag } from './extension-fixtures'; + +test('connect page is added to green Playwright group on relay connect', async ({ startExtensionClient, server }) => { + const { browserContext, client } = await startExtensionClient(); + + const connectPagePromise = browserContext.waitForEvent('page', page => + page.url().startsWith(`chrome-extension://${extensionId}/connect.html`) + ); + + const navigatePromise = client.callTool({ name: 'browser_navigate', arguments: { url: server.HELLO_WORLD } }); + const connectPage = await connectPagePromise; + + // Wait for the tab list to appear — this means connectToMCPRelay was processed + // by the background and _addTabToGroup has been called. + await expect(connectPage.locator('.tab-item').first()).toBeVisible(); + + const group = await connectPage.evaluate(async () => { + const chrome = (window as any).chrome; + const tab = await chrome.tabs.getCurrent(); + if (!tab || tab.groupId === -1) + return null; + const g = await chrome.tabGroups.get(tab.groupId); + return { color: g.color, title: g.title }; + }); + + expect(group).toEqual({ color: 'green', title: 'Playwright' }); + + await connectPage.locator('.tab-item', { hasText: 'Welcome' }).getByRole('button', { name: 'Connect' }).click(); + await navigatePromise; +}); + +test('connected tab is added to same Playwright group', async ({ browserWithExtension, startClient, server }) => { + const browserContext = await browserWithExtension.launch(); + + const page = await browserContext.newPage(); + await page.goto(server.HELLO_WORLD); + + const client = await startWithExtensionFlag(browserWithExtension, startClient); + + const connectPagePromise = browserContext.waitForEvent('page', page => + page.url().startsWith(`chrome-extension://${extensionId}/connect.html`) + ); + + const navigatePromise = client.callTool({ name: 'browser_navigate', arguments: { url: server.HELLO_WORLD } }); + const connectPage = await connectPagePromise; + + await connectPage.locator('.tab-item', { hasText: 'Title' }).getByRole('button', { name: 'Connect' }).click(); + await navigatePromise; + + const { connectGroupId, connectedGroupId } = await connectPage.evaluate(async () => { + const chrome = (window as any).chrome; + const connectTab = await chrome.tabs.getCurrent(); + const [connectedTab] = await chrome.tabs.query({ title: 'Title' }); + return { + connectGroupId: connectTab?.groupId, + connectedGroupId: connectedTab?.groupId, + }; + }); + + expect(connectGroupId).not.toBe(-1); + expect(connectedGroupId).toBe(connectGroupId); +}); + +test('tab added to group gets auto-attached', async ({ browserWithExtension, startClient, server, protocolVersion }) => { + test.skip(protocolVersion === 1, 'Multi-tab not supported in protocol v1'); + + server.setContent('/extra', 'ExtraExtra content', 'text/html'); + + const browserContext = await browserWithExtension.launch(); + + const page = await browserContext.newPage(); + await page.goto(server.HELLO_WORLD); + + const extraPage = await browserContext.newPage(); + await extraPage.goto(server.PREFIX + '/extra'); + + const client = await startWithExtensionFlag(browserWithExtension, startClient); + + const connectPagePromise = browserContext.waitForEvent('page', p => + p.url().startsWith(`chrome-extension://${extensionId}/connect.html`) + ); + + const navigatePromise = client.callTool({ name: 'browser_navigate', arguments: { url: server.HELLO_WORLD } }); + const connectPage = await connectPagePromise; + + await connectPage.locator('.tab-item', { hasText: 'Title' }).getByRole('button', { name: 'Connect' }).click(); + await navigatePromise; + + // Drag the extra tab into the Playwright group — this should auto-attach it. + await connectPage.evaluate(async (targetUrl: string) => { + const chrome = (window as any).chrome; + const connectTab = await chrome.tabs.getCurrent(); + const [extra] = await chrome.tabs.query({ url: targetUrl }); + await chrome.tabs.group({ groupId: connectTab.groupId, tabIds: [extra.id] }); + }, server.PREFIX + '/extra'); + + await expect.poll(async () => { + const r = await client.callTool({ name: 'browser_tabs', arguments: { action: 'list' } }); + return (r as any).content?.[0]?.text ?? ''; + }).toContain('Extra'); +}); + +test('tab removed from group gets auto-detached', async ({ browserWithExtension, startClient, server, protocolVersion }) => { + test.skip(protocolVersion === 1, 'Multi-tab not supported in protocol v1'); + + server.setContent('/second', 'SecondSecond', 'text/html'); + + const browserContext = await browserWithExtension.launch(); + const page = await browserContext.newPage(); + await page.goto(server.HELLO_WORLD); + + const client = await startWithExtensionFlag(browserWithExtension, startClient); + + const connectPagePromise = browserContext.waitForEvent('page', p => + p.url().startsWith(`chrome-extension://${extensionId}/connect.html`) + ); + + const navigatePromise = client.callTool({ name: 'browser_navigate', arguments: { url: server.HELLO_WORLD } }); + const connectPage = await connectPagePromise; + + await connectPage.locator('.tab-item', { hasText: 'Title' }).getByRole('button', { name: 'Connect' }).click(); + await navigatePromise; + + // Create a second tab via the client — it will be attached and added to the group. + await client.callTool({ name: 'browser_tabs', arguments: { action: 'new', url: server.PREFIX + '/second' } }); + + // The second tab is attached (has the connected badge). + await expect.poll(async () => { + return connectPage.evaluate(async (targetUrl: string) => { + const chrome = (window as any).chrome; + const [t] = await chrome.tabs.query({ url: targetUrl }); + if (!t?.id) + return ''; + return await chrome.action.getBadgeText({ tabId: t.id }); + }, server.PREFIX + '/second'); + }).toBe('✓'); + + // Ungroup the second tab — this should auto-detach it. + await connectPage.evaluate(async (targetUrl: string) => { + const chrome = (window as any).chrome; + const [second] = await chrome.tabs.query({ url: targetUrl }); + await chrome.tabs.ungroup([second.id]); + }, server.PREFIX + '/second'); + + // The badge should be cleared, indicating the tab was detached. + await expect.poll(async () => { + return connectPage.evaluate(async (targetUrl: string) => { + const chrome = (window as any).chrome; + const [t] = await chrome.tabs.query({ url: targetUrl }); + if (!t?.id) + return ''; + return await chrome.action.getBadgeText({ tabId: t.id }); + }, server.PREFIX + '/second'); + }).toBe(''); +}); + +test('connected tab is removed from group on disconnect', async ({ browserWithExtension, startClient, server }) => { + const browserContext = await browserWithExtension.launch(); + + const page = await browserContext.newPage(); + await page.goto(server.HELLO_WORLD); + + const client = await startWithExtensionFlag(browserWithExtension, startClient); + + const connectPagePromise = browserContext.waitForEvent('page', page => + page.url().startsWith(`chrome-extension://${extensionId}/connect.html`) + ); + + const navigatePromise = client.callTool({ name: 'browser_navigate', arguments: { url: server.HELLO_WORLD } }); + const connectPage = await connectPagePromise; + + await connectPage.locator('.tab-item', { hasText: 'Title' }).getByRole('button', { name: 'Connect' }).click(); + await navigatePromise; + + await client.close(); + + await expect.poll(async () => { + return connectPage.evaluate(async () => { + const chrome = (window as any).chrome; + const [tab] = await chrome.tabs.query({ title: 'Title' }); + return tab?.groupId ?? -1; + }); + }).toBe(-1); +}); diff --git a/tests/extension/tab-management.spec.ts b/tests/extension/tab-management.spec.ts new file mode 100644 index 0000000000000..4e63e237a90e7 --- /dev/null +++ b/tests/extension/tab-management.spec.ts @@ -0,0 +1,170 @@ +/** + * Copyright (c) Microsoft Corporation. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { test, expect, connectAndNavigate } from './extension-fixtures'; + +test.skip(({ protocolVersion }) => protocolVersion === 1, 'Multi-tab not supported in protocol v1'); + +test(`browser_tabs new creates a new tab`, async ({ startExtensionClient, server }) => { + server.setContent('/second.html', 'SecondSecond page', 'text/html'); + const { browserContext, client } = await startExtensionClient(); + + const navigateResponse = await connectAndNavigate(browserContext, client, server.HELLO_WORLD); + expect(navigateResponse).toHaveResponse({ + snapshot: expect.stringContaining(`- generic [active] [ref=e1]: Hello, world!`), + }); + + const newTabResponse = await client.callTool({ + name: 'browser_tabs', + arguments: { action: 'new', url: server.PREFIX + '/second.html' }, + }); + expect(newTabResponse).toHaveResponse({ + snapshot: expect.stringContaining(`- generic [active] [ref=e1]: Second page`), + }); + + const listResponse = await client.callTool({ + name: 'browser_tabs', + arguments: { action: 'list' }, + }); + expect(listResponse).toHaveResponse({ + result: expect.stringMatching(/- 0: \[Title\]\(.*\/hello-world\)\n- 1: \(current\) \[Second\]\(.*\/second\.html\)/), + }); +}); + +test(`browser_tabs select switches the active tab`, async ({ startExtensionClient, server }) => { + server.setContent('/first.html', 'FirstFirst page', 'text/html'); + server.setContent('/second.html', 'SecondSecond page', 'text/html'); + const { browserContext, client } = await startExtensionClient(); + + await connectAndNavigate(browserContext, client, server.PREFIX + '/first.html'); + + await client.callTool({ + name: 'browser_tabs', + arguments: { action: 'new', url: server.PREFIX + '/second.html' }, + }); + + const selectResponse = await client.callTool({ + name: 'browser_tabs', + arguments: { action: 'select', index: 0 }, + }); + expect(selectResponse).toHaveResponse({ + result: expect.stringMatching(/- 0: \(current\) \[First\]\(.*\/first\.html\)\n- 1: \[Second\]\(.*\/second\.html\)/), + }); + + const snapshotResponse = await client.callTool({ + name: 'browser_snapshot', + arguments: {}, + }); + expect(snapshotResponse).toHaveResponse({ + inlineSnapshot: expect.stringContaining('First page'), + }); +}); + +test(`browser_tabs close removes a tab`, async ({ startExtensionClient, server }) => { + server.setContent('/first.html', 'FirstFirst page', 'text/html'); + server.setContent('/second.html', 'SecondSecond page', 'text/html'); + const { browserContext, client } = await startExtensionClient(); + + await connectAndNavigate(browserContext, client, server.PREFIX + '/first.html'); + + await client.callTool({ + name: 'browser_tabs', + arguments: { action: 'new', url: server.PREFIX + '/second.html' }, + }); + + const closeResponse = await client.callTool({ + name: 'browser_tabs', + arguments: { action: 'close', index: 0 }, + }); + expect(closeResponse).toHaveResponse({ + result: expect.stringMatching(/^- 0: \(current\) \[Second\]\(.*\/second\.html\)$/m), + }); + + const listResponse = await client.callTool({ + name: 'browser_tabs', + arguments: { action: 'list' }, + }); + expect(listResponse).toHaveResponse({ + result: expect.not.stringContaining('First'), + }); + expect(listResponse).toHaveResponse({ + result: expect.stringContaining('Second'), + }); +}); + +test(`cmd+click opens new tab visible in tab list`, async ({ startExtensionClient, server }) => { + server.setContent('/link-page', 'LinkPageclick me', 'text/html'); + server.setContent('/target-page', 'TargetPageTarget content', 'text/html'); + const { browserContext, client } = await startExtensionClient(); + + const navigateResponse = await connectAndNavigate(browserContext, client, server.PREFIX + '/link-page'); + expect(navigateResponse).toHaveResponse({ + snapshot: expect.stringContaining(`click me`), + }); + + await client.callTool({ + name: 'browser_click', + arguments: { element: 'click me', target: 'e2', modifiers: ['ControlOrMeta'] }, + }); + + await expect.poll(async () => { + const listResponse = await client.callTool({ + name: 'browser_tabs', + arguments: { action: 'list' }, + }); + return (listResponse as any).content?.[0]?.text ?? ''; + }).toContain('TargetPage'); + + const listResponse = await client.callTool({ + name: 'browser_tabs', + arguments: { action: 'list' }, + }); + expect(listResponse).toHaveResponse({ + result: expect.stringMatching(/- 0:.*\[LinkPage\].*\n- 1:.*\[TargetPage\]/), + }); +}); + +test(`window.open from tracked tab auto-attaches new tab`, async ({ startExtensionClient, server }) => { + server.setContent('/opener-page', `Opener`, 'text/html'); + server.setContent('/opened-page', 'OpenedOpened content', 'text/html'); + const { browserContext, client } = await startExtensionClient(); + + const navigateResponse = await connectAndNavigate(browserContext, client, server.PREFIX + '/opener-page'); + expect(navigateResponse).toHaveResponse({ + snapshot: expect.stringContaining('open'), + }); + + await client.callTool({ + name: 'browser_click', + arguments: { element: 'open', target: 'e2' }, + }); + + await expect.poll(async () => { + const listResponse = await client.callTool({ + name: 'browser_tabs', + arguments: { action: 'list' }, + }); + return (listResponse as any).content?.[0]?.text ?? ''; + }).toContain('Opened'); + + const listResponse = await client.callTool({ + name: 'browser_tabs', + arguments: { action: 'list' }, + }); + expect(listResponse).toHaveResponse({ + result: expect.stringMatching(/- 0:.*\[Opener\].*\n- 1:.*\[Opened\]/), + }); +}); diff --git a/tests/mcp/cli-fixtures.ts b/tests/mcp/cli-fixtures.ts index ecb1abd9e90c1..673705307d0d9 100644 --- a/tests/mcp/cli-fixtures.ts +++ b/tests/mcp/cli-fixtures.ts @@ -28,7 +28,6 @@ export { expect } from './fixtures'; export const test = baseTest.extend<{ cliEnv: Record, openDashboard: (options?: { cwd?: string, session?: string }) => Promise, - openDashboardApp: (options?: { cwd?: string, session?: string }) => Promise, cli: (...args: any[]) => Promise<{ output: string, error: string, @@ -56,35 +55,6 @@ export const test = baseTest.extend<{ return page; }); }, - openDashboardApp: async ({ cli, playwright, findFreePort, waitForPort }, use) => { - const dashboards: { dashboard: Page, browser: import('playwright-core').Browser }[] = []; - await use(async (options?: { cwd?: string, session?: string }) => { - const debugPort = await findFreePort(); - const showArgs = options?.session ? [`-s=${options.session}`, 'show'] : ['show']; - await cli(...showArgs, { - cwd: options?.cwd, - env: { PLAYWRIGHT_DASHBOARD_DEBUG_PORT: String(debugPort) }, - }); - await waitForPort(debugPort); - const browser = await playwright.chromium.connectOverCDP(`http://127.0.0.1:${debugPort}`); - const dashboard = browser.contexts()[0].pages()[0]; - // Headless Chromium with `--app=` does not report display-mode: standalone, - // and CDP's Emulation.setEmulatedMedia doesn't honor display-mode features. - // Set the test flag directly; downloadArtifact re-reads it on every click. - await dashboard.addInitScript(() => { (window as any).__dashboardAppModeForTest = true; }); - await dashboard.evaluate(() => { (window as any).__dashboardAppModeForTest = true; }).catch(() => {}); - dashboards.push({ dashboard, browser }); - return dashboard; - }); - for (const { dashboard, browser } of dashboards) { - if (!browser.isConnected()) - continue; - await Promise.all([ - new Promise(r => browser.on('disconnected', r)), - dashboard.close(), - ]).catch(e => console.error('Error during dashboard close', e)); - } - }, cli: async ({ mcpBrowser, mcpHeadless, childProcess }, use) => { const sessions: { name: string, pid: number }[] = []; await fs.promises.mkdir(test.info().outputPath('.playwright'), { recursive: true }); @@ -114,7 +84,6 @@ function cliEnv() { return { PLAYWRIGHT_SERVER_REGISTRY: test.info().outputPath('registry'), PLAYWRIGHT_DASHBOARD_SETTINGS_FILE_FOR_TEST: test.info().outputPath('dashboard.settings.json'), - PLAYWRIGHT_DASHBOARD_DOWNLOADS_DIR_FOR_TEST: test.info().outputPath('dashboard-downloads'), PLAYWRIGHT_DAEMON_SESSION_DIR: test.info().outputPath('daemon'), PLAYWRIGHT_SOCKETS_DIR: path.join(test.info().project.outputDir, 'ds', String(test.info().parallelIndex)), PLAYWRIGHT_CLI_CHANNEL_SCAN_DISABLED_FOR_TEST: '1', diff --git a/tests/mcp/cli-json.spec.ts b/tests/mcp/cli-json.spec.ts new file mode 100644 index 0000000000000..d026b8ca9e9b7 --- /dev/null +++ b/tests/mcp/cli-json.spec.ts @@ -0,0 +1,181 @@ +/** + * Copyright (c) Microsoft Corporation. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { test, expect } from './cli-fixtures'; + +const SNAPSHOT_FILE = expect.stringMatching(/^\.playwright-cli\/page-[\dTZ:.-]+\.yml$/); + +test('output is pretty-printed', async ({ cli }) => { + const { output } = await cli('--json', 'list'); + expect(output).toBe(`{ + "browsers": [] +}`); +}); + +test('version command returns JSON', async ({ cli }) => { + const { output } = await cli('--json', '--version'); + const parsed = JSON.parse(output); + expect(parsed).toEqual({ version: expect.any(String) }); +}); + +test('unknown command returns JSON error with exit 1', async ({ cli }) => { + const { output, exitCode } = await cli('--json', 'nope'); + expect(JSON.parse(output)).toEqual({ isError: true, error: 'Unknown command: nope' }); + expect(exitCode).toBe(1); +}); + +test('unknown flag returns JSON error with exit 1', async ({ cli }) => { + const { output, exitCode } = await cli('--json', 'list', '--bogus'); + expect(JSON.parse(output)).toEqual({ isError: true, error: 'Unknown option: --bogus' }); + expect(exitCode).toBe(1); +}); + +test('running tool command without open browser returns JSON error', async ({ cli }) => { + const { output, exitCode } = await cli('--json', 'goto', 'about:blank'); + expect(JSON.parse(output)).toEqual({ + isError: true, + error: `The browser 'default' is not open, please run open first`, + }); + expect(exitCode).toBe(1); +}); + +test('list returns empty browsers before open', async ({ cli }) => { + const { output } = await cli('--json', 'list'); + expect(JSON.parse(output)).toEqual({ browsers: [] }); +}); + +test('close returns not-open status when nothing is running', async ({ cli }) => { + const { output } = await cli('--json', 'close'); + expect(JSON.parse(output)).toEqual({ session: 'default', status: 'not-open' }); +}); + +test('delete-data returns not-deleted when there is no session', async ({ cli }) => { + const { output } = await cli('--json', 'delete-data'); + expect(JSON.parse(output)).toEqual({ session: 'default', deleted: false }); +}); + +test('kill-all returns zero killed when filter matches nothing', async ({ cli }) => { + const { output } = await cli('--json', 'kill-all', { env: { PLAYWRIGHT_KILL_ALL_PID_FILTER_FOR_TEST: '0' } }); + expect(JSON.parse(output)).toEqual({ killed: 0, pids: [] }); +}); + +test('open returns envelope with session, pid and snapshot file', async ({ cli, server }) => { + const { output } = await cli('--json', 'open', server.HELLO_WORLD); + expect(JSON.parse(output)).toEqual({ + session: 'default', + pid: expect.any(Number), + result: { snapshot: { file: SNAPSHOT_FILE } }, + }); +}); + +test('list after open returns one browser entry', async ({ cli, server, mcpBrowser }) => { + await cli('open', server.HELLO_WORLD); + const { output } = await cli('--json', 'list'); + expect(JSON.parse(output)).toEqual({ + browsers: [{ + name: 'default', + workspace: expect.any(String), + status: 'open', + browserType: mcpBrowser, + userDataDir: null, + headed: false, + persistent: false, + attached: false, + compatible: true, + version: expect.any(String), + }], + }); +}); + +test('goto returns snapshot file envelope', async ({ cli, server }) => { + await cli('open', server.HELLO_WORLD); + const { output } = await cli('--json', 'goto', server.HELLO_WORLD); + expect(JSON.parse(output)).toEqual({ snapshot: { file: SNAPSHOT_FILE } }); +}); + +test('eval returns result string', async ({ cli, server }) => { + await cli('open', server.HELLO_WORLD); + const { output } = await cli('--json', 'eval', '() => 1 + 2'); + expect(JSON.parse(output)).toEqual({ result: '3' }); +}); + +test('eval with error surfaces isError JSON', async ({ cli, server }) => { + await cli('open', server.HELLO_WORLD); + const { output } = await cli('--json', 'eval', '() => { throw new Error("boom"); }'); + const parsed = JSON.parse(output); + expect(parsed.isError).toBe(true); + expect(parsed.error).toContain('boom'); +}); + +test('tab-new creates a new tab and returns tab list', async ({ cli, server }) => { + await cli('open', server.HELLO_WORLD); + const { output } = await cli('--json', 'tab-new'); + const parsed = JSON.parse(output); + expect(parsed.result.split('\n')).toEqual([ + `- 0: [Title](${server.HELLO_WORLD})`, + '- 1: (current) [](about:blank)', + ]); +}); + +test('tab-list lists all tabs', async ({ cli, server }) => { + await cli('open', server.HELLO_WORLD); + await cli('tab-new'); + const { output } = await cli('--json', 'tab-list'); + const parsed = JSON.parse(output); + expect(parsed.result.split('\n')).toEqual([ + `- 0: [Title](${server.HELLO_WORLD})`, + '- 1: (current) [](about:blank)', + ]); +}); + +test('tab-close closes a tab and returns remaining tabs', async ({ cli, server }) => { + await cli('open', server.HELLO_WORLD); + await cli('tab-new'); + const { output } = await cli('--json', 'tab-close'); + const parsed = JSON.parse(output); + expect(parsed.result.split('\n')).toEqual([ + `- 0: (current) [Title](${server.HELLO_WORLD})`, + ]); +}); + +test('snapshot returns inline snapshot yaml', async ({ cli, server }) => { + server.setContent('/', '

Hi

', 'text/html'); + await cli('open', server.PREFIX); + const { output } = await cli('--json', 'snapshot'); + const parsed = JSON.parse(output); + expect(typeof parsed.snapshot).toBe('string'); + expect(parsed.snapshot).toContain('heading "Hi"'); +}); + +test('tool error on bad navigation returns JSON error', async ({ cli, server }) => { + await cli('open', server.HELLO_WORLD); + const { output } = await cli('--json', 'goto', 'https://not-a-real-domain.example.invalid'); + const parsed = JSON.parse(output); + expect(parsed.isError).toBe(true); + expect(typeof parsed.error).toBe('string'); +}); + +test('close after open returns closed status', async ({ cli, server }) => { + await cli('open', server.HELLO_WORLD); + const { output } = await cli('--json', 'close'); + expect(JSON.parse(output)).toEqual({ session: 'default', status: 'closed' }); +}); + +test('close-all after open returns closed sessions', async ({ cli, server }) => { + await cli('open', server.HELLO_WORLD); + const { output } = await cli('--json', 'close-all'); + expect(JSON.parse(output)).toEqual({ closed: ['default'] }); +}); diff --git a/tests/mcp/dashboard.spec.ts b/tests/mcp/dashboard.spec.ts index a5eb4f9373294..34ce4d145a02f 100644 --- a/tests/mcp/dashboard.spec.ts +++ b/tests/mcp/dashboard.spec.ts @@ -92,16 +92,22 @@ function isAlive(pid: number): boolean { } } -test('daemon show: closing page exits the process', async ({ playwright, cli, findFreePort, waitForPort }) => { - const cdpPort = await findFreePort(); - const { exitCode, pid } = await cli('show', { env: { PLAYWRIGHT_PRINT_DASHBOARD_PID_FOR_TEST: '1', PLAYWRIGHT_DASHBOARD_DEBUG_PORT: String(cdpPort) } }); +test('daemon show: closing page exits the process', async ({ playwright, cli }) => { + const bindTitle = `--playwright-internal--${crypto.randomUUID()}`; + const { exitCode, pid } = await cli('show', { env: { PLAYWRIGHT_PRINT_DASHBOARD_PID_FOR_TEST: '1', PW_DASHBOARD_APP_BIND_TITLE: bindTitle } }); expect(exitCode).toBe(0); expect(pid).toBeDefined(); expect(isAlive(pid!)).toBe(true); - await waitForPort(cdpPort); + let endpoint = ''; + await expect(async () => { + const { output } = await cli('list', '--all', '--json'); + const { servers } = JSON.parse(output); + expect(servers[0].title).toBe(bindTitle); + endpoint = servers[0].endpoint; + }).toPass(); - const browser = await playwright.chromium.connectOverCDP(`http://127.0.0.1:${cdpPort}`); + const browser = await playwright.chromium.connect(endpoint); const page = browser.contexts()[0].pages()[0]; await page.close(); @@ -132,23 +138,65 @@ test('should pick locator from browser', async ({ cli, server, openDashboard }) expect(output).toContain(`getByRole('button', { name: 'Submit' })`); }); -test('screenshot triggers a browser download in browser mode', async ({ cli, server, openDashboard }) => { +async function installSaveFilePickerMock(page: import('playwright-core').Page): Promise<() => Promise> { + let captured: string | undefined; + let resolveCaptured: ((b64: string) => void) | undefined; + const waitForCapture = new Promise(resolve => { + resolveCaptured = resolve; + }); + await page.exposeBinding('__testCaptureBytes', (_, b64: string) => { + captured = b64; + resolveCaptured!(b64); + }); + await page.addInitScript(() => { + (window as any).showSaveFilePicker = async () => ({ + createWritable: async () => { + const chunks: Uint8Array[] = []; + return { + write: async (chunk: Blob | BufferSource) => { + const buf = chunk instanceof Blob + ? new Uint8Array(await chunk.arrayBuffer()) + : new Uint8Array(chunk instanceof ArrayBuffer ? chunk : (chunk as ArrayBufferView).buffer); + chunks.push(buf); + }, + close: async () => { + const total = chunks.reduce((n, c) => n + c.byteLength, 0); + const merged = new Uint8Array(total); + let offset = 0; + for (const c of chunks) { + merged.set(c, offset); + offset += c.byteLength; + } + await (window as any).__testCaptureBytes((merged as any).toBase64()); + }, + }; + }, + }); + }); + return async () => { + const b64 = captured ?? await waitForCapture; + return Buffer.from(b64, 'base64'); + }; +} + +test('screenshot writes PNG bytes to the chosen file', async ({ cli, server, page, openDashboard }) => { await cli('open', server.EMPTY_PAGE); + const awaitBytes = await installSaveFilePickerMock(page); const dashboard = await openDashboard(); await dashboard.locator('.sidebar-tab').first().click(); await expect(dashboard.locator('img#display')).toBeVisible(); await expect(dashboard.locator('.screenshot')).toBeEnabled(); - const [download] = await Promise.all([ - dashboard.waitForEvent('download'), - dashboard.locator('.screenshot').click(), - ]); - expect(download.suggestedFilename()).toMatch(/^playwright-screenshot-\d+\.png$/); + await dashboard.locator('.screenshot').click(); + + const bytes = await awaitBytes(); + expect(bytes.subarray(0, 8)).toEqual(Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a])); }); -test('recording triggers a browser download in browser mode', async ({ cli, server, openDashboard }) => { +test('stop recording streams WebM bytes to the chosen file', async ({ cli, server, page, openDashboard }) => { await cli('open', server.EMPTY_PAGE); + const awaitBytes = await installSaveFilePickerMock(page); const dashboard = await openDashboard(); await dashboard.locator('.sidebar-tab').first().click(); @@ -158,29 +206,9 @@ test('recording triggers a browser download in browser mode', async ({ cli, serv await expect(recordBtn).toBeEnabled(); await recordBtn.click(); await expect(dashboard.locator('.recording-label')).toBeVisible(); + await recordBtn.click(); - const [download] = await Promise.all([ - dashboard.waitForEvent('download'), - recordBtn.click(), - ]); - expect(download.suggestedFilename()).toMatch(/^playwright-recording-\d+\.webm$/); -}); - -test('screenshot lands in the Downloads dir in app mode', async ({ cli, server, openDashboardApp }, testInfo) => { - const downloadsDir = testInfo.outputPath('dashboard-downloads'); - await fs.promises.mkdir(downloadsDir, { recursive: true }); - - await cli('open', server.EMPTY_PAGE); - - const dashboard = await openDashboardApp(); - await dashboard.locator('.sidebar-tab').first().click(); - await expect(dashboard.locator('img#display')).toBeVisible(); - await expect(dashboard.locator('.screenshot')).toBeEnabled(); - - await dashboard.locator('.screenshot').click(); - - await expect.poll(async () => { - const entries = await fs.promises.readdir(downloadsDir).catch(() => []); - return entries.filter(f => /^playwright-screenshot-\d+\.png$/.test(f)); - }).toHaveLength(1); + const bytes = await awaitBytes(); + // WebM files start with the EBML magic bytes. + expect(bytes.subarray(0, 4)).toEqual(Buffer.from([0x1a, 0x45, 0xdf, 0xa3])); }); diff --git a/tests/mcp/mcp-server-bind.spec.ts b/tests/mcp/mcp-server-bind.spec.ts index f7931d6d3ab13..71a11d4920a40 100644 --- a/tests/mcp/mcp-server-bind.spec.ts +++ b/tests/mcp/mcp-server-bind.spec.ts @@ -26,6 +26,6 @@ test('browser started by MCP is bound to server registry', async ({ startClient, arguments: { url: server.HELLO_WORLD }, }); - const { output } = await cli('list'); + const { output } = await cli('list', '--all'); expect(output).toContain('My Agent'); }); diff --git a/tests/page/page-drop.spec.ts b/tests/page/page-drop.spec.ts new file mode 100644 index 0000000000000..32e549251266a --- /dev/null +++ b/tests/page/page-drop.spec.ts @@ -0,0 +1,120 @@ +/** + * Copyright (c) Microsoft Corporation. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import fs from 'fs'; + +import { test as it, expect } from './pageTest'; + +it.skip(({ isAndroid }) => isAndroid, 'No drag&drop on Android.'); + +async function setupDropzone(page: import('playwright-core').Page) { + await page.setContent(` + +
+ + `); +} + +it('should drop a file payload', async ({ page }) => { + await setupDropzone(page); + await page.locator('#dropzone').drop({ + files: { name: 'note.txt', mimeType: 'text/plain', buffer: Buffer.from('hello') }, + }); + await expect.poll(() => page.evaluate(() => (window as any).__dropInfo)).toEqual({ + files: [{ name: 'note.txt', type: 'text/plain', size: 5, text: 'hello' }], + data: {}, + }); +}); + +it('should drop multiple file payloads', async ({ page }) => { + await setupDropzone(page); + await page.locator('#dropzone').drop({ + files: [ + { name: 'a.txt', mimeType: 'text/plain', buffer: Buffer.from('AAA') }, + { name: 'b.txt', mimeType: 'text/plain', buffer: Buffer.from('BB') }, + ], + }); + const info = await page.evaluate(() => (window as any).__dropInfo); + expect(info.files.map((f: any) => [f.name, f.text])).toEqual([['a.txt', 'AAA'], ['b.txt', 'BB']]); +}); + +it('should drop a file by local path', async ({ page }, testInfo) => { + await setupDropzone(page); + const filePath = testInfo.outputPath('hello.txt'); + await fs.promises.writeFile(filePath, 'path-content'); + await page.locator('#dropzone').drop({ files: filePath }); + const info = await page.evaluate(() => (window as any).__dropInfo); + expect(info.files).toHaveLength(1); + expect(info.files[0].name).toBe('hello.txt'); + expect(info.files[0].text).toBe('path-content'); +}); + +it('should drop clipboard-like data', async ({ page }) => { + await setupDropzone(page); + await page.locator('#dropzone').drop({ + data: { + 'text/plain': 'hello world', + 'text/uri-list': 'https://example.com', + }, + }); + const info = await page.evaluate(() => (window as any).__dropInfo); + expect(info.files).toEqual([]); + expect(info.data['text/plain']).toBe('hello world'); + expect(info.data['text/uri-list']).toBe('https://example.com'); +}); + +it('should drop files and data together', async ({ page }) => { + await setupDropzone(page); + await page.locator('#dropzone').drop({ + files: { name: 'mix.txt', mimeType: 'text/plain', buffer: Buffer.from('mix') }, + data: { 'text/plain': 'label' }, + }); + const info = await page.evaluate(() => (window as any).__dropInfo); + expect(info.files[0].text).toBe('mix'); + expect(info.data['text/plain']).toBe('label'); +}); + +it('should throw when target does not accept drop', async ({ page }) => { + // Dropzone without preventDefault on dragover. + await page.setContent(` +
+ `); + await expect(page.locator('#dropzone').drop({ + data: { 'text/plain': 'nope' }, + })).rejects.toThrow(/drop target did not accept the drop/i); +}); + +it('should throw when neither files nor data provided', async ({ page }) => { + await setupDropzone(page); + await expect(page.locator('#dropzone').drop({})).rejects.toThrow(/At least one of "files" or "data"/); +});