diff --git a/package-lock.json b/package-lock.json index d589976358df5..d152d379aa19a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -22,7 +22,6 @@ "@modelcontextprotocol/sdk": "^1.26.0", "@octokit/graphql-schema": "^15.26.0", "@stylistic/eslint-plugin": "^5.2.3", - "@trayjs/trayjs": "^0.0.9", "@types/codemirror": "^5.60.7", "@types/formidable": "^2.0.4", "@types/mdast": "^4.0.4", @@ -1758,105 +1757,6 @@ "node": ">=10" } }, - "node_modules/@trayjs/darwin-arm64": { - "version": "0.0.9", - "resolved": "https://registry.npmjs.org/@trayjs/darwin-arm64/-/darwin-arm64-0.0.9.tgz", - "integrity": "sha512-VE94XlLstnKIuO+OmDQNUJLiw1xF9t1QEtHwpisRIAYGinL6Zw4Icvx8gFdMNSKV2PVELfMrEZ1cFugggQENZA==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "Apache-2.0", - "optional": true, - "os": [ - "darwin" - ] - }, - "node_modules/@trayjs/darwin-x64": { - "version": "0.0.9", - "resolved": "https://registry.npmjs.org/@trayjs/darwin-x64/-/darwin-x64-0.0.9.tgz", - "integrity": "sha512-o2bI0oZmX5QegmgJrko9Lvv414Og44iR4Ul3NMN+xyr7P+enOl4WnyqD+x+02ITXLS7sqEEpj3/q4KKo0soTwA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "Apache-2.0", - "optional": true, - "os": [ - "darwin" - ] - }, - "node_modules/@trayjs/linux-arm64": { - "version": "0.0.9", - "resolved": "https://registry.npmjs.org/@trayjs/linux-arm64/-/linux-arm64-0.0.9.tgz", - "integrity": "sha512-d4XDkP4DmquyDyI4im5AHZIFyqrVNywL4F4x2z4FPAonOwnpHIWehui5f6FcmPauVfFY0T3PkDi8l8THykR+jw==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "Apache-2.0", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@trayjs/linux-x64": { - "version": "0.0.9", - "resolved": "https://registry.npmjs.org/@trayjs/linux-x64/-/linux-x64-0.0.9.tgz", - "integrity": "sha512-o+6JEWMXX9jPbD8zmdTxoL+PxeKi6aII2Ig1xySPtlWFwuCdE4c/FjsCn0TnSOhLsImglmy9eg1fa9GZOdj3vA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "Apache-2.0", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@trayjs/trayjs": { - "version": "0.0.9", - "resolved": "https://registry.npmjs.org/@trayjs/trayjs/-/trayjs-0.0.9.tgz", - "integrity": "sha512-on6HVfynUkSJFanUcpQKz5PZptCRtp61AV6FZHGy1lea8wPOwgIOigVJAJ4a2EQYBghsJdcVqoY1XPyFlzvQRQ==", - "dev": true, - "license": "Apache-2.0", - "optionalDependencies": { - "@trayjs/darwin-arm64": "0.0.9", - "@trayjs/darwin-x64": "0.0.9", - "@trayjs/linux-arm64": "0.0.9", - "@trayjs/linux-x64": "0.0.9", - "@trayjs/win32-arm64": "0.0.9", - "@trayjs/win32-x64": "0.0.9" - } - }, - "node_modules/@trayjs/win32-arm64": { - "version": "0.0.9", - "resolved": "https://registry.npmjs.org/@trayjs/win32-arm64/-/win32-arm64-0.0.9.tgz", - "integrity": "sha512-2Wc5GGrVtGZFOMcyIXaJSCUaC079BRE4NdkX+PWeFAjBon8fK2Q5h3JSSslQOG/W9gXG9gzGFGUOt2w2KRI88Q==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "Apache-2.0", - "optional": true, - "os": [ - "win32" - ] - }, - "node_modules/@trayjs/win32-x64": { - "version": "0.0.9", - "resolved": "https://registry.npmjs.org/@trayjs/win32-x64/-/win32-x64-0.0.9.tgz", - "integrity": "sha512-2KxmPIszxFVTsJ4h4QPv7Z89KjIGRwyA6jhB1dNblMJv5qUEfwFK766GjkmMLAi510iyhp8YrMkS+U6sD0Jblw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "Apache-2.0", - "optional": true, - "os": [ - "win32" - ] - }, "node_modules/@types/babel__core": { "version": "7.20.5", "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", diff --git a/package.json b/package.json index 8eba09aa61f63..812df6211f400 100644 --- a/package.json +++ b/package.json @@ -62,7 +62,6 @@ "@modelcontextprotocol/sdk": "^1.26.0", "@octokit/graphql-schema": "^15.26.0", "@stylistic/eslint-plugin": "^5.2.3", - "@trayjs/trayjs": "^0.0.9", "@types/codemirror": "^5.60.7", "@types/formidable": "^2.0.4", "@types/mdast": "^4.0.4", diff --git a/packages/devtools/src/common.css b/packages/devtools/src/common.css new file mode 100644 index 0000000000000..d5a3f7ee9567a --- /dev/null +++ b/packages/devtools/src/common.css @@ -0,0 +1,66 @@ +/** + * Copyright (c) Microsoft Corporation. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +:root { + --bg: #202124; + --bg-elevated: #35363a; + --bg-card: #292b2e; + --bg-tab: #292b2e; + --fg: #e8eaed; + --fg-dim: #9aa0a6; + --fg-muted: #5f6368; + --accent: #8ab4f8; + --ok: #81c995; + --err: #f28b82; +} + +*, +*::before, +*::after { + box-sizing: border-box; + margin: 0; + padding: 0; +} + +html, +body { + height: 100%; +} + +body { + background: var(--bg); + color: var(--fg); + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif; +} + +#root { + height: 100%; +} + +button { + background: none; + border: none; + color: var(--fg-dim); + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; +} + +button:hover { + color: var(--fg); +} diff --git a/packages/devtools/src/devtools.css b/packages/devtools/src/devtools.css index 75ac4462d9854..ca5346758a1aa 100644 --- a/packages/devtools/src/devtools.css +++ b/packages/devtools/src/devtools.css @@ -14,69 +14,44 @@ * limitations under the License. */ -:root { - --bg: #202124; - --bg-elevated: #35363a; - --bg-tab: #292b2e; - --fg: #e8eaed; - --fg-dim: #9aa0a6; - --fg-muted: #5f6368; - --accent: #8ab4f8; - --ok: #81c995; - --err: #f28b82; -} - -*, -*::before, -*::after { - box-sizing: border-box; - margin: 0; - padding: 0; -} - -html, -body { - height: 100%; -} - -body { - background: var(--bg); - color: var(--fg); - font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif; +.devtools-view { display: flex; flex-direction: column; + height: 100%; overflow: hidden; } -#root { +/* -- Tab bar -- */ +.tabbar { display: flex; - flex-direction: column; - height: 100%; + align-items: flex-end; + height: 38px; + min-height: 38px; + padding: 0 10px 0 12px; + user-select: none; } -button { - background: none; - border: none; - color: var(--fg-dim); - cursor: pointer; +.tabbar-back { display: flex; align-items: center; justify-content: center; - flex-shrink: 0; + width: 28px; + height: 34px; + align-self: flex-end; + color: var(--fg-dim); + text-decoration: none; + border-radius: 8px; + margin-right: 4px; } -button:hover { +.tabbar-back:hover { + background: var(--bg-elevated); color: var(--fg); } -/* -- Tab bar -- */ -.tabbar { - display: flex; - align-items: flex-end; - height: 38px; - min-height: 38px; - padding: 0 10px 0 12px; - user-select: none; +.tabbar-back svg { + width: 16px; + height: 16px; } .tabbar-brand span { diff --git a/packages/devtools/src/devtools.tsx b/packages/devtools/src/devtools.tsx index ce0935ce0d212..9ba9bac94bc65 100644 --- a/packages/devtools/src/devtools.tsx +++ b/packages/devtools/src/devtools.tsx @@ -16,6 +16,7 @@ import React from 'react'; import './devtools.css'; +import { navigate } from './index'; import { DevToolsTransport } from './transport'; type TabInfo = { id: string; title: string; url: string }; @@ -30,7 +31,7 @@ function tabFavicon(url: string): string { } } -export const DevTools: React.FC = () => { +export const DevTools: React.FC<{ wsUrl: string }> = ({ wsUrl }) => { const [status, setStatus] = React.useState<{ text: string; cls: string }>({ text: 'Connecting', cls: '' }); const [tabs, setTabs] = React.useState([]); const [selectedPageId, setSelectedPageId] = React.useState(); @@ -54,9 +55,7 @@ export const DevTools: React.FC = () => { }, [captured]); React.useEffect(() => { - const wsProtocol = location.protocol === 'https:' ? 'wss:' : 'ws:'; - const guid = new URLSearchParams(location.search).get('ws'); - const transport = new DevToolsTransport(wsProtocol + '//' + location.host + '/' + guid); + const transport = new DevToolsTransport(wsUrl); transportRef.current = transport; transport.onopen = () => setStatus({ text: 'Connected', cls: 'connected' }); @@ -84,7 +83,7 @@ export const DevTools: React.FC = () => { transport.onclose = () => setStatus({ text: 'Disconnected', cls: 'error' }); return () => transport.close(); - }, []); + }, [wsUrl]); function resizeToFit() { const { width: vw, height: vh } = viewportSizeRef.current; @@ -209,12 +208,14 @@ export const DevTools: React.FC = () => { const hasPages = !!selectedPageId; - return (<> + return (
{/* Tab bar */}
-
- Playwright -
+ { e.preventDefault(); navigate('#'); }}> + + + +
{tabs.map(tab => (
{
No tabs open
- ); +
); }; diff --git a/packages/devtools/src/grid.css b/packages/devtools/src/grid.css new file mode 100644 index 0000000000000..4834c63084c4f --- /dev/null +++ b/packages/devtools/src/grid.css @@ -0,0 +1,154 @@ +/** + * 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. + */ + +.grid-view { + overflow: auto; + padding: 24px; + height: 100%; +} + +.grid-loading { + color: var(--fg-muted); + font-size: 14px; +} + +.grid-empty { + color: var(--fg-muted); + font-size: 14px; +} + +.grid-error { + color: var(--err); + font-size: 14px; +} + +.workspace-list { + display: flex; + flex-direction: column; + gap: 24px; +} + +.workspace-header { + font-size: 13px; + font-weight: 600; + color: var(--fg-dim); + margin-bottom: 8px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.session-chips { + display: flex; + flex-wrap: wrap; + gap: 8px; +} + +.session-chip { + display: flex; + flex-direction: column; + background: var(--bg-card); + border-radius: 8px; + border: 1px solid transparent; + min-width: 200px; + cursor: pointer; + text-decoration: none; + color: inherit; +} + +.session-chip:hover { + border-color: var(--fg-muted); +} + +.session-chip-header { + display: flex; + align-items: center; + gap: 8px; + padding: 8px 12px; +} + +.session-status-dot { + width: 8px; + height: 8px; + border-radius: 50%; + flex-shrink: 0; +} + +.session-status-dot.open { + background: var(--ok); +} + +.session-status-dot.closed { + background: var(--fg-muted); +} + +.session-chip-name { + font-size: 13px; + font-weight: 600; + color: var(--fg); +} + +.session-chip-detail { + font-size: 11px; + color: var(--fg-muted); +} + +.session-chip-close { + width: 20px; + height: 20px; + border-radius: 50%; + margin-left: auto; + opacity: 0; +} + +.session-chip-close svg { + width: 10px; + height: 10px; +} + +.session-chip:hover .session-chip-close { + opacity: 1; +} + +.session-chip-close:hover { + background: rgba(255, 255, 255, 0.12); +} + +.screencast-container { + width: 533px; + height: 300px; + background: #000; + border-radius: 4px; + overflow: hidden; +} + +.screencast-frame { + display: block; + width: 100%; + height: 100%; + object-fit: contain; + background: #000; +} + +.screencast-placeholder { + width: 100%; + height: 100%; + display: flex; + align-items: center; + justify-content: center; + color: var(--fg-muted); + font-size: 12px; +} diff --git a/packages/devtools/src/grid.tsx b/packages/devtools/src/grid.tsx new file mode 100644 index 0000000000000..9c2da1c18873f --- /dev/null +++ b/packages/devtools/src/grid.tsx @@ -0,0 +1,212 @@ +/** + * Copyright (c) Microsoft Corporation. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import React from 'react'; +import './grid.css'; +import { navigate } from './index'; +import { Screencast } from './screencast'; + +import type { SessionConfig } from '../../playwright/src/mcp/terminal/registry'; + +type SessionStatus = { + config: SessionConfig; + canConnect: boolean; +}; + +export const Grid: React.FC = () => { + const [sessions, setSessions] = React.useState([]); + const [loading, setLoading] = React.useState(true); + const [error, setError] = React.useState(); + const [screencastUrls, setScreencastUrls] = React.useState>({}); + + const lastJsonRef = React.useRef(''); + const knownTimestampsRef = React.useRef>(new Map()); + const startingRef = React.useRef>(new Set()); + + async function fetchSessions() { + try { + const response = await fetch('/api/sessions/list'); + if (!response.ok) + throw new Error(`HTTP ${response.status}`); + const text = await response.text(); + if (text !== lastJsonRef.current) { + lastJsonRef.current = text; + setSessions(JSON.parse(text)); + } + setError(undefined); + } catch (e: any) { + setError(e.message); + } finally { + setLoading(false); + } + } + + React.useEffect(() => { + let active = true; + let timeoutId: ReturnType; + async function poll() { + await fetchSessions(); + if (active) + timeoutId = setTimeout(poll, 3000); + } + poll(); + return () => { active = false; clearTimeout(timeoutId); }; + }, []); + + // Manage screencast lifecycle when sessions change. + React.useEffect(() => { + let active = true; + const liveSockets = new Set(); + + for (const { config, canConnect } of sessions) { + if (!canConnect) + continue; + const key = config.socketPath; + liveSockets.add(key); + + const known = knownTimestampsRef.current.get(key); + if (known === config.timestamp) + continue; + if (startingRef.current.has(key)) + continue; + + knownTimestampsRef.current.set(key, config.timestamp); + startingRef.current.add(key); + + void (async () => { + try { + const resp = await fetch('/api/sessions/start-screencast', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ config }), + }); + if (!resp.ok) + throw new Error(); + const { url } = await resp.json(); + if (active) + setScreencastUrls(prev => ({ ...prev, [key]: url })); + } catch { + knownTimestampsRef.current.delete(key); + } finally { + startingRef.current.delete(key); + } + })(); + } + + // Clean up sessions that are no longer live. + setScreencastUrls(prev => { + const next = { ...prev }; + let changed = false; + for (const key of Object.keys(next)) { + if (!liveSockets.has(key)) { + delete next[key]; + knownTimestampsRef.current.delete(key); + changed = true; + } + } + return changed ? next : prev; + }); + + return () => { active = false; }; + }, [sessions]); + + // Clear all screencasts on unmount. + React.useEffect(() => { + return () => setScreencastUrls({}); + }, []); + + function browserLabel(config: SessionConfig): string { + if (config.resolvedConfig) + return config.resolvedConfig.browser.launchOptions.channel ?? config.resolvedConfig.browser.browserName; + return config.cli.browser || 'chromium'; + } + + function headedLabel(config: SessionConfig): string { + if (config.resolvedConfig) + return config.resolvedConfig.browser.launchOptions.headless ? 'headless' : 'headed'; + return config.cli.headed ? 'headed' : 'headless'; + } + + + const workspaceGroups = React.useMemo(() => { + const groups = new Map(); + for (const session of sessions) { + const key = session.config.workspaceDir || 'Unknown'; + let list = groups.get(key); + if (!list) { + list = []; + groups.set(key, list); + } + list.push(session); + } + for (const list of groups.values()) + list.sort((a, b) => a.config.name.localeCompare(b.config.name)); + return [...groups.entries()].sort((a, b) => a[0].localeCompare(b[0])); + }, [sessions]); + + return (
+ {loading && sessions.length === 0 &&
Loading sessions...
} + {error &&
Error: {error}
} + {!loading && !error && sessions.length === 0 &&
No sessions found.
} + + ); +}; diff --git a/packages/devtools/src/index.tsx b/packages/devtools/src/index.tsx index fd29c7045190e..b7ea705e521ca 100644 --- a/packages/devtools/src/index.tsx +++ b/packages/devtools/src/index.tsx @@ -14,7 +14,85 @@ * limitations under the License. */ +import React from 'react'; import * as ReactDOM from 'react-dom/client'; +import './common.css'; import { DevTools } from './devtools'; +import { Grid } from './grid'; -ReactDOM.createRoot(document.querySelector('#root')!).render(); +import type { SessionConfig } from '../../playwright/src/mcp/terminal/registry'; + +export function navigate(hash: string) { + window.history.pushState(null, '', hash); + window.dispatchEvent(new PopStateEvent('popstate')); +} + +function parseHash(): string | undefined { + const hash = window.location.hash; + const prefix = '#session='; + if (hash.startsWith(prefix)) + return decodeURIComponent(hash.slice(prefix.length)); + return undefined; +} + +const DevToolsSession: React.FC<{ socketPath: string }> = ({ socketPath }) => { + const [wsUrl, setWsUrl] = React.useState(); + const [error, setError] = React.useState(); + + React.useEffect(() => { + setWsUrl(undefined); + setError(undefined); + + let cancelled = false; + + void (async () => { + try { + const listResp = await fetch('/api/sessions/list'); + if (!listResp.ok) + throw new Error(`HTTP ${listResp.status}`); + const sessions: { config: SessionConfig; canConnect: boolean }[] = await listResp.json(); + const session = sessions.find(s => s.config.socketPath === socketPath); + if (!session) + throw new Error('Session not found'); + + const startResp = await fetch('/api/sessions/start-screencast', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ config: session.config }), + }); + if (!startResp.ok) + throw new Error(`HTTP ${startResp.status}`); + const { url } = await startResp.json(); + if (!cancelled) + setWsUrl(url); + } catch (e: any) { + if (!cancelled) + setError(e.message); + } + })(); + + return () => { cancelled = true; }; + }, [socketPath]); + + if (error) + return
Error: {error}
; + if (!wsUrl) + return
Connecting to session...
; + return ; +}; + +const App: React.FC = () => { + const [socketPath, setSocketPath] = React.useState(parseHash); + + React.useEffect(() => { + const onPopState = () => setSocketPath(parseHash()); + window.addEventListener('popstate', onPopState); + return () => window.removeEventListener('popstate', onPopState); + }, []); + + if (socketPath) + return ; + return ; +}; + +ReactDOM.createRoot(document.querySelector('#root')!).render(); diff --git a/packages/devtools/src/screencast.tsx b/packages/devtools/src/screencast.tsx new file mode 100644 index 0000000000000..d537614a8de0b --- /dev/null +++ b/packages/devtools/src/screencast.tsx @@ -0,0 +1,36 @@ +/** + * 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'; +import { DevToolsTransport } from './transport'; + +export const Screencast: React.FC<{ wsUrl: string }> = ({ wsUrl }) => { + const [frameSrc, setFrameSrc] = React.useState(''); + + React.useEffect(() => { + const transport = new DevToolsTransport(wsUrl); + transport.onevent = (method: string, params: any) => { + if (method === 'frame') + setFrameSrc('data:image/jpeg;base64,' + params.data); + }; + return () => transport.close(); + }, [wsUrl]); + + if (!frameSrc) + return
Connecting...
; + + return screencast; +}; diff --git a/packages/playwright-core/src/client/page.ts b/packages/playwright-core/src/client/page.ts index 4800723f0c023..8fe1a33857ee1 100644 --- a/packages/playwright-core/src/client/page.ts +++ b/packages/playwright-core/src/client/page.ts @@ -859,6 +859,10 @@ export class Page extends ChannelOwner implements api.Page async _snapshotForAI(options: TimeoutOptions & { track?: string } = {}): Promise<{ full: string, incremental?: string }> { return await this._channel.snapshotForAI({ timeout: this._timeoutSettings.timeout(options), track: options.track }); } + + async _setDockTile(image: Buffer) { + await this._channel.setDockTile({ image }); + } } export class BindingCall extends ChannelOwner { diff --git a/packages/playwright-core/src/protocol/validator.ts b/packages/playwright-core/src/protocol/validator.ts index 1626cdec9767c..3861d86e577cd 100644 --- a/packages/playwright-core/src/protocol/validator.ts +++ b/packages/playwright-core/src/protocol/validator.ts @@ -1548,6 +1548,10 @@ scheme.PageAgentParams = tObject({ scheme.PageAgentResult = tObject({ agent: tChannel(['PageAgent']), }); +scheme.PageSetDockTileParams = tObject({ + image: tBinary, +}); +scheme.PageSetDockTileResult = tOptional(tObject({})); scheme.FrameInitializer = tObject({ url: tString, name: tString, diff --git a/packages/playwright-core/src/server/bidi/bidiPage.ts b/packages/playwright-core/src/server/bidi/bidiPage.ts index 7f58f96d43f6b..509d0e7b2cf37 100644 --- a/packages/playwright-core/src/server/bidi/bidiPage.ts +++ b/packages/playwright-core/src/server/bidi/bidiPage.ts @@ -643,6 +643,9 @@ export class BidiPage implements PageDelegate { shouldToggleStyleSheetToSyncAnimations(): boolean { return true; } + + async setDockTile(image: Buffer): Promise { + } } function toBidiExecutionContext(executionContext: dom.FrameExecutionContext): BidiExecutionContext { diff --git a/packages/playwright-core/src/server/browserContext.ts b/packages/playwright-core/src/server/browserContext.ts index d08e547c4740a..033a6bfd5e9e4 100644 --- a/packages/playwright-core/src/server/browserContext.ts +++ b/packages/playwright-core/src/server/browserContext.ts @@ -517,11 +517,10 @@ export abstract class BrowserContext extends Sdk async devtoolsStart(options: { size?: types.Size, port?: number, host?: string } = {}): Promise { if (this._devtools) - throw new Error('DevTools is already running'); + await this._devtools.stop(); const size = validateVideoSize(options.size, undefined); this._devtools = new DevToolsController(this); - const url = await this._devtools.start({ width: size.width, height: size.height, quality: 90, port: options.port, host: options.host }); - return url; + return await this._devtools.start({ width: size.width, height: size.height, quality: 90, port: options.port, host: options.host }); } async devtoolsStop(): Promise { diff --git a/packages/playwright-core/src/server/chromium/crPage.ts b/packages/playwright-core/src/server/chromium/crPage.ts index a23383b1774b8..6c4af1608a6ab 100644 --- a/packages/playwright-core/src/server/chromium/crPage.ts +++ b/packages/playwright-core/src/server/chromium/crPage.ts @@ -356,6 +356,10 @@ export class CRPage implements PageDelegate { shouldToggleStyleSheetToSyncAnimations(): boolean { return false; } + + async setDockTile(image: Buffer): Promise { + await this._mainFrameSession._client.send('Browser.setDockTile', { image: image.toString('base64') }); + } } class FrameSession { diff --git a/packages/playwright-core/src/server/devtoolsController.ts b/packages/playwright-core/src/server/devtoolsController.ts index 0c89b7ffb6f64..a0e7a8d84acab 100644 --- a/packages/playwright-core/src/server/devtoolsController.ts +++ b/packages/playwright-core/src/server/devtoolsController.ts @@ -14,7 +14,6 @@ * limitations under the License. */ -import path from 'path'; import { createGuid, eventsHelper } from '../utils'; import { HttpServer } from './utils/httpServer'; import { BrowserContext } from './browserContext'; @@ -23,7 +22,6 @@ import { ProgressController } from './progress'; import type { RegisteredListener } from '../utils'; import type { Transport } from './utils/httpServer'; -import type http from 'http'; export class DevToolsController { private _context: BrowserContext; @@ -37,21 +35,10 @@ export class DevToolsController { async start(options: { width: number, height: number, quality: number, port?: number, host?: string }): Promise { this._screencastOptions = options; - - const devtoolsDir = path.join(__dirname, '..', 'vite', 'devtools'); - this._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); - const resolved = path.join(devtoolsDir, filePath); - if (!resolved.startsWith(devtoolsDir)) - return false; - return this._httpServer.serveFile(request, response, resolved); - }); - const guid = createGuid(); this._httpServer.createWebSocket(() => new DevToolsConnection(this._context, this._screencastOptions), guid); await this._httpServer.start({ port: options.port, host: options.host }); - return this._httpServer.urlPrefix('human-readable') + `?ws=${guid}`; + return (this._httpServer.urlPrefix('human-readable') + `/${guid}`).replace('http://', 'ws://'); } async stop() { @@ -59,7 +46,6 @@ export class DevToolsController { } } - class DevToolsConnection implements Transport { sendEvent?: (method: string, params: any) => void; close?: () => void; diff --git a/packages/playwright-core/src/server/dispatchers/pageDispatcher.ts b/packages/playwright-core/src/server/dispatchers/pageDispatcher.ts index 3ba9cc20d8cc6..e4319894001a0 100644 --- a/packages/playwright-core/src/server/dispatchers/pageDispatcher.ts +++ b/packages/playwright-core/src/server/dispatchers/pageDispatcher.ts @@ -407,6 +407,10 @@ export class PageDispatcher extends Dispatcher {}); this._cssCoverageActive = false; } + + async setDockTile(params: channels.PageSetDockTileParams): Promise { + await this._page.setDockTile(params.image); + } } diff --git a/packages/playwright-core/src/server/firefox/ffPage.ts b/packages/playwright-core/src/server/firefox/ffPage.ts index 57c27f1bb10b0..9677ab4493743 100644 --- a/packages/playwright-core/src/server/firefox/ffPage.ts +++ b/packages/playwright-core/src/server/firefox/ffPage.ts @@ -559,6 +559,9 @@ export class FFPage implements PageDelegate { shouldToggleStyleSheetToSyncAnimations(): boolean { return false; } + + async setDockTile(image: Buffer): Promise { + } } function webSocketId(frameId: string, wsid: string): string { diff --git a/packages/playwright-core/src/server/index.ts b/packages/playwright-core/src/server/index.ts index df4b183504dbb..3f127cb9158a9 100644 --- a/packages/playwright-core/src/server/index.ts +++ b/packages/playwright-core/src/server/index.ts @@ -28,5 +28,4 @@ export { createPlaywright } from './playwright'; export type { DispatcherScope } from './dispatchers/dispatcher'; export type { Playwright } from './playwright'; -export { installRootRedirect, openTraceInBrowser, openTraceViewerApp, startTraceViewerServer, openUrlInApp } from './trace/viewer/traceViewer'; -export { ProgressController } from './progress'; +export { installRootRedirect, openTraceInBrowser, openTraceViewerApp, startTraceViewerServer } from './trace/viewer/traceViewer'; diff --git a/packages/playwright-core/src/server/page.ts b/packages/playwright-core/src/server/page.ts index 5333595ac53b9..90553ed1129d2 100644 --- a/packages/playwright-core/src/server/page.ts +++ b/packages/playwright-core/src/server/page.ts @@ -97,6 +97,7 @@ export interface PageDelegate { resetForReuse(progress: Progress): Promise; // WebKit hack. shouldToggleStyleSheetToSyncAnimations(): boolean; + setDockTile(image: Buffer): Promise; } type EmulatedSize = { screen: types.Size, viewport: types.Size }; @@ -867,6 +868,10 @@ export class Page extends SdkObject { const snapshot = await snapshotFrameForAI(progress, this.mainFrame(), options); return { full: snapshot.full.join('\n'), incremental: snapshot.incremental?.join('\n') }; } + + async setDockTile(image: Buffer) { + await this.delegate.setDockTile(image); + } } export const WorkerEvent = { diff --git a/packages/playwright-core/src/server/trace/viewer/traceViewer.ts b/packages/playwright-core/src/server/trace/viewer/traceViewer.ts index 91f9f1f43bff3..392fe08eeb744 100644 --- a/packages/playwright-core/src/server/trace/viewer/traceViewer.ts +++ b/packages/playwright-core/src/server/trace/viewer/traceViewer.ts @@ -210,26 +210,6 @@ export async function openTraceInBrowser(url: string) { await open(url.replace('0.0.0.0', 'localhost')).catch(() => {}); } -export async function openUrlInApp(url: string, options?: { name?: string }) { - const localPlaywright = createPlaywright({ sdkLanguage: 'javascript', isInternalPlaywright: true }); - const { context, page } = await launchApp(localPlaywright['chromium'], { - sdkLanguage: 'javascript', - windowSize: { width: 1280, height: 800 }, - persistentContextOptions: { - headless: false, - }, - }); - - const controller = new ProgressController(); - await controller.run(async progress => { - await context._browser._defaultContext!._loadDefaultContextAsIs(progress); - if (options?.name) - await syncLocalStorageWithSettings(page, options.name); - await page.mainFrame().goto(progress, url); - }); - return page; -} - class StdinServer implements Transport { private _pollTimer: NodeJS.Timeout | undefined; private _traceUrl: string | undefined; diff --git a/packages/playwright-core/src/server/webkit/wkPage.ts b/packages/playwright-core/src/server/webkit/wkPage.ts index 04de0bda667e6..071b9727dfad7 100644 --- a/packages/playwright-core/src/server/webkit/wkPage.ts +++ b/packages/playwright-core/src/server/webkit/wkPage.ts @@ -1230,6 +1230,9 @@ export class WKPage implements PageDelegate { shouldToggleStyleSheetToSyncAnimations(): boolean { return true; } + + async setDockTile(image: Buffer): Promise { + } } class WKFrame { diff --git a/packages/playwright-core/src/utils/isomorphic/protocolMetainfo.ts b/packages/playwright-core/src/utils/isomorphic/protocolMetainfo.ts index 8d6dea7fc9933..cc1bc350736b4 100644 --- a/packages/playwright-core/src/utils/isomorphic/protocolMetainfo.ts +++ b/packages/playwright-core/src/utils/isomorphic/protocolMetainfo.ts @@ -146,6 +146,7 @@ export const methodMetainfo = new Map[] = [ ...config, ...console, ...cookies, + ...devtools, ...dialogs, ...evaluate, ...files, @@ -62,7 +63,6 @@ export const browserTools: Tool[] = [ ...route, ...runCode, ...screenshot, - ...show, ...snapshot, ...storage, ...tabs, diff --git a/packages/playwright/src/mcp/browser/tools/show.ts b/packages/playwright/src/mcp/browser/tools/devtools.ts similarity index 62% rename from packages/playwright/src/mcp/browser/tools/show.ts rename to packages/playwright/src/mcp/browser/tools/devtools.ts index 863b0d8761b48..2dacf6bdd31f3 100644 --- a/packages/playwright/src/mcp/browser/tools/show.ts +++ b/packages/playwright/src/mcp/browser/tools/devtools.ts @@ -17,13 +17,14 @@ import { z } from 'playwright-core/lib/mcpBundle'; import { defineTool } from './tool'; -const show = defineTool({ +const devtoolsStart = defineTool({ capability: 'devtools', + skillOnly: true, schema: { - name: 'browser_show', - title: 'Show browser DevTools', - description: 'Show browser DevTools', + name: 'browser_devtools_start', + title: 'Start browser DevTools', + description: 'Start browser DevTools', inputSchema: z.object({ host: z.string().optional().describe('Host to use'), port: z.number().optional().describe('Port to use'), @@ -35,8 +36,27 @@ const show = defineTool({ handle: async (context, params, response) => { const browserContext = await context.ensureBrowserContext(); const { url } = await (browserContext as any)._devtoolsStart(params); - response.addTextResult('Show server is listening on: ' + url); + response.addTextResult('Server is listening on: ' + url); }, }); -export default [show]; +const devtoolsStop = defineTool({ + capability: 'devtools', + skillOnly: true, + + schema: { + name: 'browser_devtools_stop', + title: 'Stop browser DevTools', + description: 'Stop browser DevTools', + inputSchema: z.object({ + }), + type: 'action', + }, + + handle: async (context, params, response) => { + const browserContext = await context.ensureBrowserContext(); + await (browserContext as any)._devtoolsStop(); + }, +}); + +export default [devtoolsStart, devtoolsStop]; diff --git a/packages/playwright/src/mcp/terminal/DEPS.list b/packages/playwright/src/mcp/terminal/DEPS.list index 9189f792d0789..6827d182e4fd1 100644 --- a/packages/playwright/src/mcp/terminal/DEPS.list +++ b/packages/playwright/src/mcp/terminal/DEPS.list @@ -9,6 +9,11 @@ [program.ts] "strict" +./session.ts +./registry.ts + +[session.ts] +"strict" ./socketConnection.ts ./registry.ts diff --git a/packages/playwright/src/mcp/terminal/appIcon.png b/packages/playwright/src/mcp/terminal/appIcon.png new file mode 100644 index 0000000000000..1c898466493eb Binary files /dev/null and b/packages/playwright/src/mcp/terminal/appIcon.png differ diff --git a/packages/playwright/src/mcp/terminal/commands.ts b/packages/playwright/src/mcp/terminal/commands.ts index f8beb4ae8224b..c77a1afe7e098 100644 --- a/packages/playwright/src/mcp/terminal/commands.ts +++ b/packages/playwright/src/mcp/terminal/commands.ts @@ -763,12 +763,30 @@ const videoStop = declareCommand({ toolParams: ({ filename }) => ({ filename }), }); -const show = declareCommand({ +const devtoolsShow = declareCommand({ name: 'show', description: 'Show browser DevTools', category: 'devtools', args: z.object({}), - toolName: 'browser_show', + toolName: '', + toolParams: () => ({}), +}); + +const devtoolsStart = declareCommand({ + name: 'devtools-start', + description: 'Show browser DevTools', + category: 'devtools', + args: z.object({}), + toolName: 'browser_devtools_start', + toolParams: () => ({}), +}); + +const devtoolsStop = declareCommand({ + name: 'devtools-stop', + description: 'Stop browser DevTools', + category: 'devtools', + args: z.object({}), + toolName: 'browser_devtools_stop', toolParams: () => ({}), }); @@ -934,11 +952,13 @@ const commandsArray: AnyCommandSchema[] = [ // devtools category networkRequests, - show, tracingStart, tracingStop, videoStart, videoStop, + devtoolsShow, + devtoolsStart, + devtoolsStop, // session category sessionList, diff --git a/packages/playwright/src/mcp/terminal/devtoolsApp.ts b/packages/playwright/src/mcp/terminal/devtoolsApp.ts new file mode 100644 index 0000000000000..f50d405543d06 --- /dev/null +++ b/packages/playwright/src/mcp/terminal/devtoolsApp.ts @@ -0,0 +1,262 @@ +/** + * Copyright (c) Microsoft Corporation. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import fs from 'fs'; +import path from 'path'; +import os from 'os'; +import net from 'net'; + +import { chromium } from 'playwright-core'; +import { gracefullyProcessExitDoNotHang, HttpServer } from 'playwright-core/lib/utils'; +import { findChromiumChannelBestEffort, registryDirectory } from 'playwright-core/lib/server/registry/index'; + +import { createClientInfo, Registry } from './registry'; +import { Session } from './session'; + +import type http from 'http'; +import type { Page } from 'playwright-core'; +import type { ClientInfo, SessionConfig } from './registry'; + +function readBody(request: http.IncomingMessage): Promise { + return new Promise((resolve, reject) => { + const chunks: Buffer[] = []; + request.on('data', (chunk: Buffer) => chunks.push(chunk)); + request.on('end', () => { + try { + const text = Buffer.concat(chunks).toString(); + resolve(text ? JSON.parse(text) : {}); + } catch (e) { + reject(e); + } + }); + request.on('error', reject); + }); +} + +function sendJSON(response: http.ServerResponse, data: any, statusCode = 200) { + response.statusCode = statusCode; + response.setHeader('Content-Type', 'application/json'); + response.end(JSON.stringify(data)); +} + +async function handleApiRequest(clientInfo: ClientInfo, request: http.IncomingMessage, response: http.ServerResponse) { + const url = new URL(request.url!, `http://${request.headers.host}`); + const apiPath = url.pathname; + + if (apiPath === '/api/sessions/list' && request.method === 'GET') { + const registry = await Registry.load(); + const result: { config: SessionConfig, canConnect: boolean }[] = []; + for (const [, entries] of registry.entryMap()) { + for (const entry of entries) { + const session = new Session(clientInfo, entry.config); + const canConnect = await session.canConnect(); + if (canConnect || entry.config.cli.persistent) + result.push({ config: entry.config, canConnect }); + else + await session.deleteSessionConfig(); + } + } + sendJSON(response, result); + return; + } + + if (apiPath === '/api/sessions/close' && request.method === 'POST') { + const body = await readBody(request); + if (!body.config) + throw new Error('Missing "config" parameter'); + await new Session(clientInfo, body.config).stop(); + sendJSON(response, { success: true }); + return; + } + + if (apiPath === '/api/sessions/run' && request.method === 'POST') { + const body = await readBody(request); + if (!body.config) + throw new Error('Missing "config" parameter'); + if (!body.args) + throw new Error('Missing "args" parameter'); + const result = await new Session(clientInfo, body.config).run(body.args); + sendJSON(response, { result }); + return; + } + + if (apiPath === '/api/sessions/start-screencast' && request.method === 'POST') { + const body = await readBody(request); + if (!body.config) + throw new Error('Missing "config" parameter'); + const result = await new Session(clientInfo, body.config).run({ _: ['devtools-start'] }); + const match = result.text.match(/Server is listening on: (.+)/); + if (!match) + throw new Error('Failed to parse screencast URL from: ' + result.text); + sendJSON(response, { url: match[1] }); + return; + } + + if (apiPath === '/api/sessions/stop-screencast' && request.method === 'POST') { + const body = await readBody(request); + if (!body.config) + throw new Error('Missing "config" parameter'); + await new Session(clientInfo, body.config).run({ _: ['devtools-stop'] }); + sendJSON(response, { success: true }); + return; + } + + response.statusCode = 404; + response.end(JSON.stringify({ error: 'Not found' })); +} + +async function openDevToolsApp(): Promise { + const httpServer = new HttpServer(); + const libDir = require.resolve('playwright-core/package.json'); + const devtoolsDir = path.join(path.dirname(libDir), 'lib/vite/devtools'); + const clientInfo = createClientInfo(); + + httpServer.routePrefix('/api/', (request: http.IncomingMessage, response: http.ServerResponse) => { + handleApiRequest(clientInfo, request, response).catch(e => { + response.statusCode = 500; + response.end(JSON.stringify({ error: e.message })); + }); + return true; + }); + + 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); + const resolved = path.join(devtoolsDir, filePath); + if (!resolved.startsWith(devtoolsDir)) + return false; + return httpServer.serveFile(request, response, resolved); + }); + await httpServer.start(); + const url = httpServer.urlPrefix('human-readable'); + + const { page } = await launchApp('devtools'); + await page.goto(url); + return page; +} + +async function launchApp(appName: string) { + const channel = findChromiumChannelBestEffort('javascript'); + const context = await chromium.launchPersistentContext('', { + ignoreDefaultArgs: ['--enable-automation'], + channel, + headless: false, + args: [ + '--app=data:text/html,', + '--test-type=', + `--window-size=1280,800`, + `--window-position=100,100`, + ], + viewport: null, + }); + + const [page] = context.pages(); + // Chromium on macOS opens a new tab when clicking on the dock icon. + // See https://github.com/microsoft/playwright/issues/9434 + if (process.platform === 'darwin') { + context.on('page', async newPage => { + if (newPage.mainFrame().url() === 'chrome://new-tab-page/') { + await page.bringToFront(); + await newPage.close(); + } + }); + } + + page.on('close', () => { + gracefullyProcessExitDoNotHang(0); + }); + + const image = await fs.promises.readFile(path.join(__dirname, 'appIcon.png')); + await (page as any)._setDockTile(image); + await syncLocalStorageWithSettings(page, appName); + return { context, page }; +} + +export async function syncLocalStorageWithSettings(page: Page, appName: string) { + const settingsFile = path.join(registryDirectory, '.settings', `${appName}.json`); + + await page.exposeBinding('_saveSerializedSettings', (_, settings) => { + fs.mkdirSync(path.dirname(settingsFile), { recursive: true }); + fs.writeFileSync(settingsFile, settings); + }); + + const settings = await fs.promises.readFile(settingsFile, 'utf-8').catch(() => ('{}')); + await page.addInitScript( + `(${String((settings: any) => { + // iframes w/ snapshots, etc. + if (location && location.protocol === 'data:') + return; + if (window.top !== window) + return; + Object.entries(settings).map(([k, v]) => localStorage[k] = v); + (window as any).saveSettings = () => { + (window as any)._saveSerializedSettings(JSON.stringify({ ...localStorage })); + }; + })})(${settings}); + `); +} + +function socketsDirectory() { + return process.env.PLAYWRIGHT_DAEMON_SOCKETS_DIR || path.join(os.tmpdir(), 'playwright-cli'); +} + +function devtoolsSocketPath() { + return process.platform === 'win32' + ? `\\\\.\\pipe\\playwright-devtools-${process.env.USERNAME || 'default'}` + : path.join(socketsDirectory(), 'devtools.sock'); +} + +async function acquireSingleton(): Promise { + const socketPath = devtoolsSocketPath(); + + return await new Promise((resolve, reject) => { + const server = net.createServer(); + server.listen(socketPath, () => resolve(server)); + server.on('error', (err: NodeJS.ErrnoException) => { + if (err.code !== 'EADDRINUSE') + return reject(err); + const client = net.connect(socketPath, () => { + client.write('bringToFront'); + client.end(); + reject(new Error('already running')); + }); + client.on('error', () => { + if (process.platform !== 'win32') + fs.unlinkSync(socketPath); + server.listen(socketPath, () => resolve(server)); + }); + }); + }); +} + +async function main() { + let server: net.Server | undefined; + process.on('exit', () => server?.close()); + try { + server = await acquireSingleton(); + } catch { + return; + } + const page = await openDevToolsApp(); + server.on('connection', socket => { + socket.on('data', data => { + if (data.toString() === 'bringToFront') + page?.bringToFront().catch(() => {}); + }); + }); +} + +void main(); diff --git a/packages/playwright/src/mcp/terminal/program.ts b/packages/playwright/src/mcp/terminal/program.ts index 3d88fa257a299..405e2f33ee387 100644 --- a/packages/playwright/src/mcp/terminal/program.ts +++ b/packages/playwright/src/mcp/terminal/program.ts @@ -19,15 +19,12 @@ import { execSync, spawn } from 'child_process'; -import crypto from 'crypto'; import fs from 'fs'; -import net from 'net'; import os from 'os'; import path from 'path'; -import { SocketConnection } from './socketConnection'; -import { baseDaemonDir, Registry } from './registry'; +import { createClientInfo, Registry } from './registry'; +import { Session, renderResolvedConfig } from './session'; -import type { FullConfig } from '../browser/config'; import type { Config } from '../config'; import type { SessionConfig, ClientInfo } from './registry'; @@ -36,267 +33,6 @@ type MinimistArgs = { [key: string]: any; }; -export class Session { - readonly name: string; - readonly config: SessionConfig; - private _connection: SocketConnection | undefined; - private _nextMessageId = 1; - private _callbacks = new Map void, reject: (e: Error) => void, method: string, params: any }>(); - private _clientInfo: ClientInfo; - - constructor(clientInfo: ClientInfo, options: SessionConfig) { - this._clientInfo = clientInfo; - this.config = options; - this.name = options.name; - } - - isCompatible(): boolean { - return this._clientInfo.version === this.config.version; - } - - checkCompatible() { - if (!this.isCompatible()) { - throw new Error(`Client is v${this._clientInfo.version}, session '${this.name}' is v${this.config.version}. Run - - playwright-cli${this.name !== 'default' ? ` -s=${this.name}` : ''} open - -to restart the browser session.`); - } - } - - async run(args: MinimistArgs): Promise<{ text: string }> { - this.checkCompatible(); - const result = await this._send('run', { args, cwd: process.cwd() }); - this.disconnect(); - return result; - } - - async stop(quiet: boolean = false): Promise { - if (!await this.canConnect()) { - if (!quiet) - console.log(`Browser '${this.name}' is not open.`); - return; - } - - await this._stopDaemon(); - if (!quiet) - console.log(`Browser '${this.name}' closed\n`); - } - - private async _send(method: string, params: any = {}): Promise { - const connection = await this._startDaemonIfNeeded(); - const messageId = this._nextMessageId++; - const message = { - id: messageId, - method, - params, - version: this.config.version, - }; - const responsePromise = new Promise((resolve, reject) => { - this._callbacks.set(messageId, { resolve, reject, method, params }); - }); - const [result] = await Promise.all([responsePromise, connection.send(message)]); - return result; - } - - disconnect() { - if (!this._connection) - return; - for (const callback of this._callbacks.values()) - callback.reject(new Error('Session closed')); - this._callbacks.clear(); - this._connection.close(); - this._connection = undefined; - } - - async deleteData() { - await this.stop(); - - const dataDirs = await fs.promises.readdir(this._clientInfo.daemonProfilesDir).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; - } - - for (const entry of matchingEntries) { - const userDataDir = path.resolve(this._clientInfo.daemonProfilesDir, 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}'.`); - break; - } catch (e: any) { - if (e.code === 'ENOENT') { - console.log(`No user data found for browser '${this.name}'.`); - break; - } - await new Promise(resolve => setTimeout(resolve, 1000)); - if (i === 4) - throw e; - } - } - } - } - - async _connect(): Promise<{ socket?: net.Socket, error?: Error }> { - return await new Promise(resolve => { - const socket = net.createConnection(this.config.socketPath, () => { - resolve({ socket }); - }); - socket.on('error', error => { - if (os.platform() !== 'win32') - void fs.promises.unlink(this.config.socketPath).catch(() => {}).then(() => resolve({ error })); - else - resolve({ error }); - }); - }); - } - - async canConnect(): Promise { - const { socket } = await this._connect(); - if (socket) { - socket.destroy(); - return true; - } - return false; - } - - private async _startDaemonIfNeeded() { - if (this._connection) - return this._connection; - - let { socket } = await this._connect(); - if (!socket) - socket = await this._startDaemon(); - - this._connection = new SocketConnection(socket, this.config.version); - this._connection.onmessage = message => this._onMessage(message); - this._connection.onclose = () => this.disconnect(); - return this._connection; - } - - private _onMessage(object: { id: number, error?: string, result: any }) { - if (object.id && this._callbacks.has(object.id)) { - const callback = this._callbacks.get(object.id)!; - this._callbacks.delete(object.id); - if (object.error) - callback.reject(new Error(object.error)); - else - callback.resolve(object.result); - } else if (object.id) { - throw new Error(`Unexpected message id: ${object.id}`); - } else { - throw new Error(`Unexpected message without id: ${JSON.stringify(object)}`); - } - } - - private _sessionFile(suffix: string) { - return path.resolve(this._clientInfo.daemonProfilesDir, `${this.name}${suffix}`); - } - - private async _startDaemon(): Promise { - await fs.promises.mkdir(this._clientInfo.daemonProfilesDir, { recursive: true }); - const cliPath = path.join(__dirname, '../../../cli.js'); - - const sessionConfigFile = this._sessionFile('.session'); - this.config.version = this._clientInfo.version; - await fs.promises.writeFile(sessionConfigFile, JSON.stringify(this.config, null, 2)); - - const errLog = this._sessionFile('.err'); - const err = fs.openSync(errLog, 'w'); - - const args = [ - cliPath, - 'run-mcp-server', - `--daemon-session=${sessionConfigFile}`, - ]; - - const child = spawn(process.execPath, args, { - detached: true, - stdio: ['ignore', 'pipe', err], - cwd: process.cwd(), // Will be used as root. - }); - - let signalled = false; - const sigintHandler = () => { - signalled = true; - child.kill('SIGINT'); - }; - const sigtermHandler = () => { - signalled = true; - child.kill('SIGTERM'); - }; - process.on('SIGINT', sigintHandler); - process.on('SIGTERM', sigtermHandler); - - let outLog = ''; - await new Promise((resolve, reject) => { - child.stdout!.on('data', data => { - outLog += data.toString(); - if (!outLog.includes('')) - return; - const errorMatch = outLog.match(/### Error\n([\s\S]*)/); - const error = errorMatch ? errorMatch[1].trim() : undefined; - if (error) { - const errLogContent = fs.readFileSync(errLog, 'utf-8'); - const message = error + (errLogContent ? '\n' + errLogContent : ''); - reject(new Error(message)); - } - - const successMatch = outLog.match(/### Success\nDaemon listening on (.*)\n/); - if (successMatch) - resolve(); - }); - child.on('close', code => { - if (!signalled) - reject(new Error(`Daemon process exited with code ${code}`)); - }); - }); - - process.off('SIGINT', sigintHandler); - process.off('SIGTERM', sigtermHandler); - child.stdout!.destroy(); - child.unref(); - - const { socket } = await this._connect(); - if (socket) { - console.log(`### Browser \`${this.name}\` opened with pid ${child.pid}.`); - const resolvedConfig = await parseResolvedConfig(outLog); - if (resolvedConfig) { - this.config.resolvedConfig = resolvedConfig; - console.log(`- ${this.name}:`); - console.log(renderResolvedConfig(resolvedConfig).join('\n')); - } - console.log(`---`); - - await fs.promises.writeFile(sessionConfigFile, JSON.stringify(this.config, null, 2)); - return socket; - } - - console.error(`Failed to connect to daemon at ${this.config.socketPath}`); - process.exit(1); - } - - private async _stopDaemon(): Promise { - let error: Error | undefined; - await this._send('stop').catch(e => { error = e; }); - if (os.platform() !== 'win32') - await fs.promises.unlink(this.config.socketPath).catch(() => {}); - - this.disconnect(); - if (!this.config.cli.persistent) - await this.deleteSessionConfig(); - if (error && !error?.message?.includes('Session closed')) - throw error; - } - - async deleteSessionConfig() { - await fs.promises.rm(this._sessionFile('.session')).catch(() => {}); - } -} - function resolveSessionName(sessionName?: string): string { if (sessionName) return sessionName; @@ -305,41 +41,6 @@ function resolveSessionName(sessionName?: string): string { return 'default'; } -export function createClientInfo(): ClientInfo { - const packageLocation = require.resolve('../../../package.json'); - const packageJSON = require(packageLocation); - const workspaceDir = findWorkspaceDir(process.cwd()); - const version = process.env.PLAYWRIGHT_CLI_VERSION_FOR_TEST || packageJSON.version; - - const hash = crypto.createHash('sha1'); - hash.update(workspaceDir || packageLocation); - const workspaceDirHash = hash.digest('hex').substring(0, 16); - - return { - version, - workspaceDir, - workspaceDirHash, - daemonProfilesDir: daemonProfilesDir(workspaceDirHash), - }; -} - -function findWorkspaceDir(startDir: string): string | undefined { - let dir = startDir; - for (let i = 0; i < 10; i++) { - if (fs.existsSync(path.join(dir, '.playwright'))) - return dir; - const parentDir = path.dirname(dir); - if (parentDir === dir) - break; - dir = parentDir; - } - return undefined; -} - -const daemonProfilesDir = (workspaceDirHash: string) => { - return path.join(baseDaemonDir, workspaceDirHash); -}; - type GlobalOptions = { help?: boolean; session?: string; @@ -475,8 +176,8 @@ export async function program() { case 'install': await install(args); return; - case 'tray': { - const daemonScript = path.join(__dirname, 'trayDaemon.js'); + case 'show': { + const daemonScript = path.join(__dirname, 'devtoolsApp.js'); const child = spawn(process.execPath, [daemonScript], { detached: true, stdio: 'ignore', @@ -602,6 +303,7 @@ function sessionConfigFromArgs(clientInfo: ClientInfo, sessionName: string, args return { name: sessionName, version: clientInfo.version, + timestamp: 0, socketPath: daemonSocketPath(clientInfo, sessionName), cli: { headed: args.headed, @@ -722,33 +424,3 @@ async function renderSessionStatus(session: Session) { text.push(...renderResolvedConfig(config.resolvedConfig)); return text.join('\n'); } - -function renderResolvedConfig(resolvedConfig: FullConfig) { - const channel = resolvedConfig.browser.launchOptions.channel ?? resolvedConfig.browser.browserName; - const lines = []; - if (channel) - lines.push(` - browser-type: ${channel}`); - if (resolvedConfig.browser.isolated) - lines.push(` - user-data-dir: `); - else - lines.push(` - user-data-dir: ${resolvedConfig.browser.userDataDir}`); - lines.push(` - headed: ${!resolvedConfig.browser.launchOptions.headless}`); - return lines; -} - -async function parseResolvedConfig(errLog: string): Promise { - const marker = '### Config\n```json\n'; - const markerIndex = errLog.indexOf(marker); - if (markerIndex === -1) - return null; - const jsonStart = markerIndex + marker.length; - const jsonEnd = errLog.indexOf('\n```', jsonStart); - if (jsonEnd === -1) - throw null; - const jsonString = errLog.substring(jsonStart, jsonEnd).trim(); - try { - return JSON.parse(jsonString) as FullConfig; - } catch { - return null; - } -} diff --git a/packages/playwright/src/mcp/terminal/registry.ts b/packages/playwright/src/mcp/terminal/registry.ts index 325d656815304..e03eae6474d0b 100644 --- a/packages/playwright/src/mcp/terminal/registry.ts +++ b/packages/playwright/src/mcp/terminal/registry.ts @@ -14,6 +14,7 @@ * limitations under the License. */ +import crypto from 'crypto'; import fs from 'fs'; import os from 'os'; import path from 'path'; @@ -30,6 +31,7 @@ export type ClientInfo = { export type SessionConfig = { name: string; version: string; + timestamp: number; socketPath: string; cli: { headed?: boolean; @@ -78,6 +80,8 @@ export class Registry { // Sessions from 0.1.0 support. if (!config.name) config.name = path.basename(file, '.session'); + if (!config.timestamp) + config.timestamp = 0; return { file, config }; } catch { return undefined; @@ -129,3 +133,38 @@ export const baseDaemonDir = (() => { throw new Error('Unsupported platform: ' + process.platform); return path.join(localCacheDir, 'ms-playwright', 'daemon'); })(); + +export function createClientInfo(): ClientInfo { + const packageLocation = require.resolve('../../../package.json'); + const packageJSON = require(packageLocation); + const workspaceDir = findWorkspaceDir(process.cwd()); + const version = process.env.PLAYWRIGHT_CLI_VERSION_FOR_TEST || packageJSON.version; + + const hash = crypto.createHash('sha1'); + hash.update(workspaceDir || packageLocation); + const workspaceDirHash = hash.digest('hex').substring(0, 16); + + return { + version, + workspaceDir, + workspaceDirHash, + daemonProfilesDir: daemonProfilesDir(workspaceDirHash), + }; +} + +function findWorkspaceDir(startDir: string): string | undefined { + let dir = startDir; + for (let i = 0; i < 10; i++) { + if (fs.existsSync(path.join(dir, '.playwright'))) + return dir; + const parentDir = path.dirname(dir); + if (parentDir === dir) + break; + dir = parentDir; + } + return undefined; +} + +const daemonProfilesDir = (workspaceDirHash: string) => { + return path.join(baseDaemonDir, workspaceDirHash); +}; diff --git a/packages/playwright/src/mcp/terminal/session.ts b/packages/playwright/src/mcp/terminal/session.ts new file mode 100644 index 0000000000000..60daf0ebed8ae --- /dev/null +++ b/packages/playwright/src/mcp/terminal/session.ts @@ -0,0 +1,332 @@ +/** + * 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 { spawn } from 'child_process'; + +import fs from 'fs'; +import net from 'net'; +import os from 'os'; +import path from 'path'; +import { SocketConnection } from './socketConnection'; + +import type { FullConfig } from '../browser/config'; +import type { SessionConfig, ClientInfo } from './registry'; + +type MinimistArgs = { + _: string[]; + [key: string]: any; +}; + +export class Session { + readonly name: string; + readonly config: SessionConfig; + private _connection: SocketConnection | undefined; + private _nextMessageId = 1; + private _callbacks = new Map void, reject: (e: Error) => void, method: string, params: any }>(); + private _clientInfo: ClientInfo; + + constructor(clientInfo: ClientInfo, options: SessionConfig) { + this._clientInfo = clientInfo; + this.config = options; + this.name = options.name; + } + + isCompatible(): boolean { + return this._clientInfo.version === this.config.version; + } + + checkCompatible() { + if (!this.isCompatible()) { + throw new Error(`Client is v${this._clientInfo.version}, session '${this.name}' is v${this.config.version}. Run + + playwright-cli${this.name !== 'default' ? ` -s=${this.name}` : ''} open + +to restart the browser session.`); + } + } + + async open(): Promise { + await this._startDaemonIfNeeded(); + this.disconnect(); + } + + async run(args: MinimistArgs, cwd?: string): Promise<{ text: string }> { + this.checkCompatible(); + const result = await this._send('run', { args, cwd: cwd || process.cwd() }); + this.disconnect(); + return result; + } + + async stop(quiet: boolean = false): Promise { + if (!await this.canConnect()) { + if (!quiet) + console.log(`Browser '${this.name}' is not open.`); + return; + } + + await this._stopDaemon(); + if (!quiet) + console.log(`Browser '${this.name}' closed\n`); + } + + private async _send(method: string, params: any = {}): Promise { + const connection = await this._startDaemonIfNeeded(); + const messageId = this._nextMessageId++; + const message = { + id: messageId, + method, + params, + version: this.config.version, + }; + const responsePromise = new Promise((resolve, reject) => { + this._callbacks.set(messageId, { resolve, reject, method, params }); + }); + const [result] = await Promise.all([responsePromise, connection.send(message)]); + return result; + } + + disconnect() { + if (!this._connection) + return; + for (const callback of this._callbacks.values()) + callback.reject(new Error('Session closed')); + this._callbacks.clear(); + this._connection.close(); + this._connection = undefined; + } + + async deleteData() { + await this.stop(); + + const dataDirs = await fs.promises.readdir(this._clientInfo.daemonProfilesDir).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; + } + + for (const entry of matchingEntries) { + const userDataDir = path.resolve(this._clientInfo.daemonProfilesDir, 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}'.`); + break; + } catch (e: any) { + if (e.code === 'ENOENT') { + console.log(`No user data found for browser '${this.name}'.`); + break; + } + await new Promise(resolve => setTimeout(resolve, 1000)); + if (i === 4) + throw e; + } + } + } + } + + async _connect(): Promise<{ socket?: net.Socket, error?: Error }> { + return await new Promise(resolve => { + const socket = net.createConnection(this.config.socketPath, () => { + resolve({ socket }); + }); + socket.on('error', error => { + if (os.platform() !== 'win32') + void fs.promises.unlink(this.config.socketPath).catch(() => {}).then(() => resolve({ error })); + else + resolve({ error }); + }); + }); + } + + async canConnect(): Promise { + const { socket } = await this._connect(); + if (socket) { + socket.destroy(); + return true; + } + return false; + } + + private async _startDaemonIfNeeded() { + if (this._connection) + return this._connection; + + let { socket } = await this._connect(); + if (!socket) + socket = await this._startDaemon(); + + this._connection = new SocketConnection(socket, this.config.version); + this._connection.onmessage = message => this._onMessage(message); + this._connection.onclose = () => this.disconnect(); + return this._connection; + } + + private _onMessage(object: { id: number, error?: string, result: any }) { + if (object.id && this._callbacks.has(object.id)) { + const callback = this._callbacks.get(object.id)!; + this._callbacks.delete(object.id); + if (object.error) + callback.reject(new Error(object.error)); + else + callback.resolve(object.result); + } else if (object.id) { + throw new Error(`Unexpected message id: ${object.id}`); + } else { + throw new Error(`Unexpected message without id: ${JSON.stringify(object)}`); + } + } + + private _sessionFile(suffix: string) { + return path.resolve(this._clientInfo.daemonProfilesDir, `${this.name}${suffix}`); + } + + private async _startDaemon(): Promise { + await fs.promises.mkdir(this._clientInfo.daemonProfilesDir, { recursive: true }); + const cliPath = path.join(__dirname, '../../../cli.js'); + + const sessionConfigFile = this._sessionFile('.session'); + this.config.version = this._clientInfo.version; + this.config.timestamp = Date.now(); + await fs.promises.writeFile(sessionConfigFile, JSON.stringify(this.config, null, 2)); + + const errLog = this._sessionFile('.err'); + const err = fs.openSync(errLog, 'w'); + + const args = [ + cliPath, + 'run-mcp-server', + `--daemon-session=${sessionConfigFile}`, + ]; + + const child = spawn(process.execPath, args, { + detached: true, + stdio: ['ignore', 'pipe', err], + cwd: process.cwd(), // Will be used as root. + }); + + let signalled = false; + const sigintHandler = () => { + signalled = true; + child.kill('SIGINT'); + }; + const sigtermHandler = () => { + signalled = true; + child.kill('SIGTERM'); + }; + process.on('SIGINT', sigintHandler); + process.on('SIGTERM', sigtermHandler); + + let outLog = ''; + await new Promise((resolve, reject) => { + child.stdout!.on('data', data => { + outLog += data.toString(); + if (!outLog.includes('')) + return; + const errorMatch = outLog.match(/### Error\n([\s\S]*)/); + const error = errorMatch ? errorMatch[1].trim() : undefined; + if (error) { + const errLogContent = fs.readFileSync(errLog, 'utf-8'); + const message = error + (errLogContent ? '\n' + errLogContent : ''); + reject(new Error(message)); + } + + const successMatch = outLog.match(/### Success\nDaemon listening on (.*)\n/); + if (successMatch) + resolve(); + }); + child.on('close', code => { + if (!signalled) + reject(new Error(`Daemon process exited with code ${code}`)); + }); + }); + + process.off('SIGINT', sigintHandler); + process.off('SIGTERM', sigtermHandler); + child.stdout!.destroy(); + child.unref(); + + const { socket } = await this._connect(); + if (socket) { + console.log(`### Browser \`${this.name}\` opened with pid ${child.pid}.`); + const resolvedConfig = await parseResolvedConfig(outLog); + if (resolvedConfig) { + this.config.resolvedConfig = resolvedConfig; + console.log(`- ${this.name}:`); + console.log(renderResolvedConfig(resolvedConfig).join('\n')); + } + console.log(`---`); + + this.config.timestamp = Date.now(); + await fs.promises.writeFile(sessionConfigFile, JSON.stringify(this.config, null, 2)); + return socket; + } + + console.error(`Failed to connect to daemon at ${this.config.socketPath}`); + process.exit(1); + } + + private async _stopDaemon(): Promise { + let error: Error | undefined; + await this._send('stop').catch(e => { error = e; }); + if (os.platform() !== 'win32') + await fs.promises.unlink(this.config.socketPath).catch(() => {}); + + this.disconnect(); + if (!this.config.cli.persistent) + await this.deleteSessionConfig(); + if (error && !error?.message?.includes('Session closed')) + throw error; + } + + async deleteSessionConfig() { + await fs.promises.rm(this._sessionFile('.session')).catch(() => {}); + } +} + +export function renderResolvedConfig(resolvedConfig: FullConfig) { + const channel = resolvedConfig.browser.launchOptions.channel ?? resolvedConfig.browser.browserName; + const lines = []; + if (channel) + lines.push(` - browser-type: ${channel}`); + if (resolvedConfig.browser.isolated) + lines.push(` - user-data-dir: `); + else + lines.push(` - user-data-dir: ${resolvedConfig.browser.userDataDir}`); + lines.push(` - headed: ${!resolvedConfig.browser.launchOptions.headless}`); + return lines; +} + +async function parseResolvedConfig(errLog: string): Promise { + const marker = '### Config\n```json\n'; + const markerIndex = errLog.indexOf(marker); + if (markerIndex === -1) + return null; + const jsonStart = markerIndex + marker.length; + const jsonEnd = errLog.indexOf('\n```', jsonStart); + if (jsonEnd === -1) + throw null; + const jsonString = errLog.substring(jsonStart, jsonEnd).trim(); + try { + return JSON.parse(jsonString) as FullConfig; + } catch { + return null; + } +} diff --git a/packages/playwright/src/mcp/terminal/trayDaemon.ts b/packages/playwright/src/mcp/terminal/trayDaemon.ts deleted file mode 100644 index 9d41dfe51da06..0000000000000 --- a/packages/playwright/src/mcp/terminal/trayDaemon.ts +++ /dev/null @@ -1,175 +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 net from 'net'; -import path from 'path'; -import os from 'os'; - -import { openUrlInApp, ProgressController } from 'playwright-core/lib/server'; -import { gracefullyProcessExitDoNotHang } from 'playwright-core/lib/utils'; - -import { createClientInfo, Session } from './program'; -import { Registry } from './registry'; - -import type { SessionEntry } from './registry'; -import type { MenuItem } from '@trayjs/trayjs'; - -const socketsDir = process.env.PLAYWRIGHT_DAEMON_SOCKETS_DIR || path.join(os.tmpdir(), 'playwright-cli'); - -const socketPath = process.platform === 'win32' - ? `\\\\.\\pipe\\playwright-tray-${process.env.USERNAME || 'default'}` - : path.join(socketsDir, 'tray.sock'); - -function acquireSingleton(): Promise { - return new Promise((resolve, reject) => { - const server = net.createServer(); - server.listen(socketPath, () => resolve(server)); - server.on('error', (err: NodeJS.ErrnoException) => { - if (err.code !== 'EADDRINUSE') - return reject(err); - const client = net.connect(socketPath, () => { - client.destroy(); - reject(new Error('already running')); - }); - client.on('error', () => { - fs.unlinkSync(socketPath); - server.listen(socketPath, () => resolve(server)); - }); - }); - }); -} - -let lastId = 0; -const idToEntry = new Map(); - -async function onMenuRequested(): Promise { - const registry = await Registry.load(); - const map = registry.entryMap(); - const items: MenuItem[] = []; - let first = true; - idToEntry.clear(); - for (const [workspace, entries] of map) { - if (!first) - items.push({ id: '', separator: true }); - first = false; - const shortName = path.basename(workspace); - items.push({ id: '', title: shortName, enabled: false }); - for (const entry of entries) { - idToEntry.set(String(++lastId), entry); - items.push({ - id: ``, - title: entry.config.name, - items: [ - { id: `show:${lastId}`, title: 'Show' }, - { id: `close:${lastId}`, title: 'Close' }, - ], - }); - } - } - - if (!items.length) - items.push({ id: '', title: 'No sessions', enabled: false }); - - items.push({ id: '', separator: true }); - items.push({ id: 'quit', title: 'Quit' }); - return items; -} - -async function main() { - let server: net.Server; - try { - server = await acquireSingleton(); - } catch { - return; - } - - const { Tray } = await import('@trayjs/trayjs'); - - const tray = new Tray({ - tooltip: 'Playwright', - icon: { - png: path.join(__dirname, 'icon.png'), - ico: path.join(__dirname, 'icon.ico'), - }, - onClicked: (id: string) => { - if (id === 'quit') - tray.quit(); - if (id.startsWith('show:')) - show(idToEntry.get(id.substring('show:'.length))).catch(() => {}); - if (id.startsWith('close:')) { - const entry = idToEntry.get(id.substring('close:'.length)); - if (entry) - new Session(createClientInfo(), entry.config).stop().catch(() => {}); - } - }, - onMenuRequested, - }); - - const shutdown = () => { - server.close(); - tray.quit(); - }; - tray.on('ready', () => {}); - process.on('SIGINT', shutdown); - process.on('SIGTERM', shutdown); - tray.on('close', () => gracefullyProcessExitDoNotHang(0)); -} - -async function runShow(entry: SessionEntry): Promise { - const s = new Session(createClientInfo(), entry.config); - const { text } = await s.run({ _: ['show'] }); - return text.match(/Show server is listening on: (.+)/)?.[1]; -} - -async function show(entry: SessionEntry | undefined) { - if (!entry) - return; - const url = await runShow(entry); - if (!url) - return; - - const page = await openUrlInApp(url, { name: 'devtools' }).catch(() => null); - if (!page) - return; - - let closed = false; - page.on('close', () => { - closed = true; - }); - - while (!closed) { - await new Promise(resolve => setTimeout(resolve, 2000)); - if (closed) - break; - try { - const freshEntry = await Registry.loadSessionEntry(entry.file); - if (!freshEntry) - continue; - const newUrl = await runShow(freshEntry); - if (!newUrl) - continue; - const controller = new ProgressController(); - await controller.run(async progress => { - await page.mainFrame().goto(progress, newUrl); - }); - } catch { - // Session might be restarting, try again next poll. - } - } -} - -void main(); diff --git a/packages/protocol/src/channels.d.ts b/packages/protocol/src/channels.d.ts index 3e062a28fb555..165caeae220a1 100644 --- a/packages/protocol/src/channels.d.ts +++ b/packages/protocol/src/channels.d.ts @@ -2128,6 +2128,7 @@ export interface PageChannel extends PageEventTarget, EventTargetChannel { videoStop(params?: PageVideoStopParams, progress?: Progress): Promise; updateSubscription(params: PageUpdateSubscriptionParams, progress?: Progress): Promise; agent(params: PageAgentParams, progress?: Progress): Promise; + setDockTile(params: PageSetDockTileParams, progress?: Progress): Promise; } export type PageBindingCallEvent = { binding: BindingCallChannel, @@ -2684,6 +2685,13 @@ export type PageAgentOptions = { export type PageAgentResult = { agent: PageAgentChannel, }; +export type PageSetDockTileParams = { + image: Binary, +}; +export type PageSetDockTileOptions = { + +}; +export type PageSetDockTileResult = void; export interface PageEvents { 'bindingCall': PageBindingCallEvent; diff --git a/packages/protocol/src/protocol.yml b/packages/protocol/src/protocol.yml index 0db5adb34d4a7..d6e204d6292ca 100644 --- a/packages/protocol/src/protocol.yml +++ b/packages/protocol/src/protocol.yml @@ -2096,6 +2096,11 @@ Page: returns: agent: PageAgent + setDockTile: + internal: true + parameters: + image: binary + events: bindingCall: diff --git a/tests/library/browsercontext-devtools.spec.ts b/tests/library/browsercontext-devtools.spec.ts deleted file mode 100644 index d05fc182517b7..0000000000000 --- a/tests/library/browsercontext-devtools.spec.ts +++ /dev/null @@ -1,137 +0,0 @@ -/** - * Copyright Microsoft Corporation. All rights reserved. - * - * 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 { contextTest, expect } from '../config/browserTest'; - -import type { Page } from 'playwright-core'; - -const it = contextTest.extend<{ rcPage: Page }>({ - rcPage: async ({ context, browserType }, use) => { - const { url } = await (context as any)._devtoolsStart(); - const rcBrowser = await browserType.launch(); - const rcPage = await rcBrowser.newPage(); - await rcPage.goto(url); - await use(rcPage); - await rcBrowser.close(); - await (context as any)._devtoolsStop(); - }, -}); - -it('should show connected status', async ({ rcPage }) => { - await expect(rcPage.locator('#status')).toHaveText('Connected'); - await expect(rcPage.locator('#status')).toHaveClass(/connected/); -}); - -it('should show tab title after navigation', async ({ rcPage, page, server }) => { - await page.goto(server.PREFIX + '/title.html'); - await expect(rcPage.locator('#tabstrip')).toMatchAriaSnapshot(` - - tablist: - - /children: equal - - tab "Woof-Woof" [selected] - `); -}); - -it('should show multiple tabs', async ({ rcPage, context, page, server }) => { - await page.goto(server.PREFIX + '/title.html'); - const page2 = await context.newPage(); - await page2.goto(server.EMPTY_PAGE); - await expect(rcPage.locator('#tabstrip')).toMatchAriaSnapshot(` - - tablist: - - /children: equal - - tab "Woof-Woof" [selected] - - tab /.*/ [selected=false] - `); -}); - -it('should switch active tab on click', async ({ rcPage, context, page, server }) => { - await page.goto(server.PREFIX + '/title.html'); - const page2 = await context.newPage(); - await page2.goto(server.EMPTY_PAGE); - await expect(rcPage.locator('#tabstrip')).toMatchAriaSnapshot(` - - tablist: - - /children: equal - - tab "Woof-Woof" [selected] - - tab /.*/ [selected=false] - `); - // Click the second (unselected) tab. - await rcPage.locator('#tabstrip [role="tab"]').last().click(); - await expect(rcPage.locator('#tabstrip')).toMatchAriaSnapshot(` - - tablist: - - /children: equal - - tab "Woof-Woof" [selected=false] - - tab /.*/ [selected] - `); -}); - -it('should close tab via close button', async ({ rcPage, context, page, server }) => { - await page.goto(server.PREFIX + '/title.html'); - const page2 = await context.newPage(); - await page2.goto(server.EMPTY_PAGE); - await expect(rcPage.locator('#tabstrip')).toMatchAriaSnapshot(` - - tablist: - - /children: equal - - tab "Woof-Woof" [selected] - - tab /.*/ [selected=false] - `); - // Close the second tab. - await rcPage.locator('#tabstrip [role="tab"]').last().locator('.tab-close').click(); - await expect(rcPage.locator('#tabstrip')).toMatchAriaSnapshot(` - - tablist: - - /children: equal - - tab "Woof-Woof" [selected] - `); -}); - -it('should show no-pages placeholder when all tabs are closed', async ({ rcPage, page }) => { - await expect(rcPage.locator('#tabstrip')).toMatchAriaSnapshot(` - - tablist: - - /children: equal - - tab /.*/ [selected] - `); - await page.close(); - await expect(rcPage.locator('#tabstrip')).toMatchAriaSnapshot(` - - /children: deep-equal - - tablist - `); - await expect(rcPage.locator('#no-pages')).toBeVisible(); - await expect(rcPage.locator('#no-pages')).toHaveText('No tabs open'); -}); - -it('should open new tab via new-tab button', async ({ rcPage }) => { - await expect(rcPage.locator('#tabstrip')).toMatchAriaSnapshot(` - - /children: deep-equal - - tablist - `); - await rcPage.locator('#new-tab-btn').click(); - await expect(rcPage.locator('#tabstrip')).toMatchAriaSnapshot(` - - tablist: - - /children: equal - - tab /.*/ [selected] - `); -}); - -it('should update omnibox on navigation', async ({ rcPage, page, server }) => { - await page.goto(server.PREFIX + '/title.html'); - await expect(rcPage.locator('#omnibox')).toMatchAriaSnapshot(` - - /children: deep-equal - - textbox "Search or enter URL": /.*\/title\.html/ - `); -}); - -it('should display screencast image', async ({ rcPage, page }) => { - await page.goto('data:text/html,'); - await expect(rcPage.locator('#display')).toHaveAttribute('src', /^data:image\/jpeg;base64,/); -}); diff --git a/tests/mcp/cli-isolated.spec.ts b/tests/mcp/cli-isolated.spec.ts index 368e5d02b83ea..ff9c6c278f631 100644 --- a/tests/mcp/cli-isolated.spec.ts +++ b/tests/mcp/cli-isolated.spec.ts @@ -27,6 +27,7 @@ test('should not save user data by default (in-memory mode)', async ({ cli, serv name: 'default', cli: {}, socketPath: expect.any(String), + timestamp: expect.any(Number), userDataDirPrefix: expect.any(String), version: expect.any(String), workspaceDir: testInfo.outputPath(), @@ -53,6 +54,7 @@ test('should save user data with --persistent flag', async ({ cli, server, mcpBr persistent: true, }, socketPath: expect.any(String), + timestamp: expect.any(Number), userDataDirPrefix: expect.any(String), version: expect.any(String), workspaceDir: testInfo.outputPath(), @@ -72,6 +74,7 @@ test('should use custom user data dir with --profile=', async ({ cli, serve profile: customDir, }, socketPath: expect.any(String), + timestamp: expect.any(Number), userDataDirPrefix: expect.any(String), version: expect.any(String), workspaceDir: testInfo.outputPath(),