From 151ff1e8a769384c801bd928490e0afdc3efd8cc Mon Sep 17 00:00:00 2001 From: Pavel Feldman Date: Mon, 9 Mar 2026 17:00:06 -0700 Subject: [PATCH 1/5] chore: follow up to trace filter, rework (#39577) --- docs/src/trace-viewer.md | 4 +- packages/trace-viewer/src/ui/actionList.css | 7 ++ packages/trace-viewer/src/ui/actionList.tsx | 122 ++------------------ packages/trace-viewer/src/ui/workbench.css | 20 +--- packages/web/src/components/treeView.tsx | 22 +++- 5 files changed, 38 insertions(+), 137 deletions(-) diff --git a/docs/src/trace-viewer.md b/docs/src/trace-viewer.md index 2192b98d60c61..ed5a855e5c22b 100644 --- a/docs/src/trace-viewer.md +++ b/docs/src/trace-viewer.md @@ -686,9 +686,7 @@ public class WithTestNameAttribute : BeforeAfterTestAttribute ## Trace Viewer features ### Actions -In the Actions tab you can see what locator was used for every action and how long each one took to run. Use the **Filter actions** search field at the top of the list to filter the action hierarchy by text; only actions whose title matches the search (and their parent actions) are shown. Clear the filter to show all actions again; expanded nodes are preserved when the filter is removed. - -Hover over each action of your test and visually see the change in the DOM snapshot. Go back and forward in time and click an action to inspect and debug. Use the Before and After tabs to visually see what happened before and after the action. +In the Actions tab you can see what locator was used for every action and how long each one took to run. Hover over each action of your test and visually see the change in the DOM snapshot. Go back and forward in time and click an action to inspect and debug. Use the Before and After tabs to visually see what happened before and after the action. actions tab in trace viewer diff --git a/packages/trace-viewer/src/ui/actionList.css b/packages/trace-viewer/src/ui/actionList.css index a9339c6a9779f..435b6ada3f161 100644 --- a/packages/trace-viewer/src/ui/actionList.css +++ b/packages/trace-viewer/src/ui/actionList.css @@ -123,3 +123,10 @@ height: 28px; align-items: center; } + +.action-list-container { + min-height: 0; + flex: auto; + display: flex; + flex-direction: column; +} diff --git a/packages/trace-viewer/src/ui/actionList.tsx b/packages/trace-viewer/src/ui/actionList.tsx index 8ca695579829e..56fb5e0e16319 100644 --- a/packages/trace-viewer/src/ui/actionList.tsx +++ b/packages/trace-viewer/src/ui/actionList.tsx @@ -29,92 +29,6 @@ import { testStatusIcon } from './testUtils'; import { methodMetainfo } from '@isomorphic/protocolMetainfo'; import { formatProtocolParam } from '@isomorphic/protocolFormatter'; -function getTitleFormat(action: ActionTraceEvent): string { - const metaTitle = methodMetainfo.get(`${action.class}.${action.method}`)?.title; - const raw = action.title ?? metaTitle ?? action.method ?? ''; - return String(raw).replace(/\n/g, ' '); -} - -function expandPlaceholders(format: string, params: Record): string { - return format.replace(/\{([^}]+)\}/g, (fullMatch, paramKey) => { - const value = formatProtocolParam(params, paramKey); - return value === undefined ? fullMatch : String(value); - }); -} - -export function getActionSearchText(action: ActionTraceEvent): string { - try { - const titleFormat = getTitleFormat(action); - return expandPlaceholders(titleFormat, action.params ?? {}); - } catch { - return String(action.title ?? action.method ?? ''); - } -} - -function computeVisibleCallIds( - actionFilterText: string | undefined, - itemMap: Map, -): Set | null { - const query = actionFilterText?.trim().toLowerCase(); - if (!query) - return null; - - const matchingCallIds = new Set(); - for (const item of itemMap.values()) { - const callId = item.action.callId; - if (!callId) - continue; - - const searchText = getActionSearchText(item.action).toLowerCase(); - if (searchText.includes(query)) - matchingCallIds.add(callId); - } - - const visibleCallIds = new Set(); - - const addAncestors = (item: ActionTreeItem | undefined) => { - if (!item) - return; - - const callId = item.action.callId; - if (callId && visibleCallIds.has(callId)) - return; - - if (callId) - visibleCallIds.add(callId); - - if (item.parent) - addAncestors(item.parent); - }; - - for (const callId of matchingCallIds) - addAncestors(itemMap.get(callId)); - - for (const callId of matchingCallIds) - visibleCallIds.add(callId); - - return visibleCallIds; -} - -function expandTreeForCallIds( - callIdsToExpand: Set, - itemMap: Map, - previousState: TreeState, -): TreeState { - const expandedItems = new Map(previousState.expandedItems); - - for (const callId of callIdsToExpand) { - const item = itemMap.get(callId); - if (!item) - continue; - - for (let parent: ActionTreeItem | undefined = item.parent; parent && parent.action.callId; parent = parent.parent) - expandedItems.set(parent.action.callId, true); - } - - return { ...previousState, expandedItems }; -} - export interface ActionListProps { actions: ActionTraceEventInContext[], selectedAction: ActionTraceEventInContext | undefined, @@ -155,27 +69,6 @@ export const ActionList: React.FC = ({ return { selectedItem }; }, [itemMap, selectedAction]); - const visibleCallIds = React.useMemo(() => { - return computeVisibleCallIds(actionFilterText, itemMap); - }, [itemMap, actionFilterText]); - - const prevVisibleCallIdsRef = React.useRef | null>(null); - React.useEffect(() => { - if (visibleCallIds) { - prevVisibleCallIdsRef.current = visibleCallIds; - return; - } - - const previousVisibleCallIds = prevVisibleCallIdsRef.current; - if (!previousVisibleCallIds) - return; - - prevVisibleCallIdsRef.current = null; - setTreeState(previousState => - expandTreeForCallIds(previousVisibleCallIds, itemMap, previousState), - ); - }, [visibleCallIds, itemMap, setTreeState]); - const isError = React.useCallback((item: ActionTreeItem) => { return !!item.action.error?.message; }, []); @@ -193,10 +86,12 @@ export const ActionList: React.FC = ({ const timeVisible = !selectedTime || !item.action || (item.action.startTime <= selectedTime.maximum && item.action.endTime >= selectedTime.minimum); if (!timeVisible) return false; - if (!visibleCallIds || !item.action.callId) + const title = renderTitleForCall(item.action).title; + if (!actionFilterText) return true; - return visibleCallIds.has(item.action.callId); - }, [selectedTime, visibleCallIds]); + const isIncluded = title.toLowerCase().includes(actionFilterText.toLowerCase()); + return isIncluded ? true : 'if-needed'; + }, [selectedTime, actionFilterText]); const onSelectedAction = React.useCallback((item: ActionTreeItem) => { onSelected?.(item.action); @@ -266,7 +161,7 @@ export const renderAction = ( ; }; -export function renderTitleForCall(action: ActionTraceEvent): { elements: React.ReactNode[], title: string } { +export function renderTitleForCall(action: ActionTraceEvent, sdkLanguage?: Language): { elements: React.ReactNode[], title: string } { let titleFormat = action.title ?? methodMetainfo.get(action.class + '.' + action.method)?.title ?? action.method; titleFormat = titleFormat.replace(/\n/g, ' '); @@ -303,5 +198,10 @@ export function renderTitleForCall(action: ActionTraceEvent): { elements: React. title.push(chunk); } + const locator = action.params.selector ? asLocatorDescription(sdkLanguage || 'javascript', action.params.selector) : undefined; + if (locator) { + title.push(' '); + title.push(locator); + } return { elements, title: title.join('') }; } diff --git a/packages/trace-viewer/src/ui/workbench.css b/packages/trace-viewer/src/ui/workbench.css index b557d7aa03fa6..928b2e5845063 100644 --- a/packages/trace-viewer/src/ui/workbench.css +++ b/packages/trace-viewer/src/ui/workbench.css @@ -46,30 +46,12 @@ } .workbench-action-filter { - flex: none; - padding: 4px; + margin-top: 3px; border-bottom: 1px solid var(--vscode-panel-border); } .workbench-action-filter input[type=search] { width: 100%; - box-sizing: border-box; padding: 4px 8px; line-height: 20px; - outline: none; - border: 1px solid var(--vscode-input-border); - border-radius: 2px; - color: var(--vscode-input-foreground); - background-color: var(--vscode-input-background); -} - -.workbench-action-filter input[type=search]:focus { - border-color: var(--vscode-focusBorder); -} - -.action-list-container { - min-height: 0; - flex: auto; - display: flex; - flex-direction: column; } diff --git a/packages/web/src/components/treeView.tsx b/packages/web/src/components/treeView.tsx index 60d3886d4d8ca..b2f98af0dcbb2 100644 --- a/packages/web/src/components/treeView.tsx +++ b/packages/web/src/components/treeView.tsx @@ -35,7 +35,7 @@ export type TreeViewProps = { title?: (item: T) => string, icon?: (item: T) => string | undefined, isError?: (item: T) => boolean, - isVisible?: (item: T) => boolean, + isVisible?: (item: T) => boolean | 'if-needed', selectedItem?: T, onAccepted?: (item: T) => void, onSelected?: (item: T) => void, @@ -336,13 +336,27 @@ type TreeItemData = { prev: TreeItem | null; }; +function isEffectivelyVisible( + item: T, + isVisible: (item: T) => boolean | 'if-needed', + cache: Map): boolean { + const cached = cache.get(item.id); + if (cached !== undefined) + return cached; + const v = isVisible(item); + const result = v === 'if-needed' ? item.children.some(child => isEffectivelyVisible(child as T, isVisible, cache)) : v; + cache.set(item.id, result); + return result; +} + function indexTree( rootItem: T, selectedItem: T | undefined, expandedItems: Map, autoExpandDepth: number, - isVisible: (item: T) => boolean = () => true): Map { - if (!isVisible(rootItem)) + isVisible: (item: T) => boolean | 'if-needed' = () => true): Map { + const visibilityCache = new Map(); + if (!isEffectivelyVisible(rootItem, isVisible, visibilityCache)) return new Map(); const result = new Map(); @@ -353,7 +367,7 @@ function indexTree( const appendChildren = (parent: T, depth: number) => { for (const item of parent.children as T[]) { - if (!isVisible(item)) + if (!isEffectivelyVisible(item, isVisible, visibilityCache)) continue; const expandState = temporaryExpanded.has(item.id) || expandedItems.get(item.id); const autoExpandMatches = autoExpandDepth > depth && result.size < 25 && expandState !== false; From 4833908b5f467a74ea435eefb1a8557a06135e67 Mon Sep 17 00:00:00 2001 From: Yury Semikhatsky Date: Mon, 9 Mar 2026 17:55:54 -0700 Subject: [PATCH 2/5] chore: switch devtools to browser registry (#39571) --- packages/devtools/src/grid.tsx | 45 +- packages/devtools/src/index.tsx | 2 +- packages/devtools/src/sessionModel.ts | 47 +- .../playwright-core/src/cli/daemon/program.ts | 7 + .../src/client/browserContext.ts | 4 - .../src/client/eventEmitter.ts | 9 +- .../playwright-core/src/devtools/DEPS.list | 5 +- .../src/devtools/devtoolsApp.ts | 132 +++--- .../src/devtools/devtoolsController.ts | 347 +++++++++++++++ .../playwright-core/src/protocol/validator.ts | 4 - .../playwright-core/src/server/browser.ts | 7 +- .../src/server/browserContext.ts | 10 - .../src/server/devtoolsController.ts | 404 ------------------ .../dispatchers/browserContextDispatcher.ts | 5 - .../playwright-core/src/serverRegistry.ts | 13 +- .../src/utils/isomorphic/protocolMetainfo.ts | 1 - packages/protocol/src/channels.d.ts | 6 - packages/protocol/src/protocol.yml | 5 - 18 files changed, 485 insertions(+), 568 deletions(-) create mode 100644 packages/playwright-core/src/devtools/devtoolsController.ts delete mode 100644 packages/playwright-core/src/server/devtoolsController.ts diff --git a/packages/devtools/src/grid.tsx b/packages/devtools/src/grid.tsx index 4266690313de0..2dd8de1601429 100644 --- a/packages/devtools/src/grid.tsx +++ b/packages/devtools/src/grid.tsx @@ -21,7 +21,7 @@ import { navigate } from './index'; import { Screencast } from './screencast'; import { SettingsButton } from './settingsView'; -import type { SessionFile } from '../../playwright-core/src/cli/client/registry'; +import type { BrowserDescriptor } from '../../playwright-core/src/serverRegistry'; import type { Tab } from './devtoolsChannel'; import type { SessionModel, SessionStatus } from './sessionModel'; @@ -44,7 +44,7 @@ export const Grid: React.FC<{ model: SessionModel }> = ({ model }) => { const workspaceGroups = React.useMemo(() => { const groups = new Map(); for (const session of sessions) { - const key = session.file.config.workspaceDir || 'Global'; + const key = session.browserDescriptor.workspaceDir || 'Global'; let list = groups.get(key); if (!list) { list = []; @@ -53,7 +53,7 @@ export const Grid: React.FC<{ model: SessionModel }> = ({ model }) => { list.push(session); } for (const list of groups.values()) - list.sort((a, b) => a.file.config.name.localeCompare(b.file.config.name)); + list.sort((a, b) => a.browserDescriptor.title.localeCompare(b.browserDescriptor.title)); // Current workspace first, then alphabetical. const entries = [...groups.entries()]; @@ -91,7 +91,7 @@ export const Grid: React.FC<{ model: SessionModel }> = ({ model }) => { {isExpanded && (
- {entries.map(({ file, canConnect }) => )} + {entries.map(session => )}
)} @@ -102,16 +102,14 @@ export const Grid: React.FC<{ model: SessionModel }> = ({ model }) => { ); }; -const SessionChip: React.FC<{ sessionFile: SessionFile; canConnect: boolean; visible: boolean; model: SessionModel }> = ({ sessionFile, canConnect, visible, model }) => { - const { config } = sessionFile; - const href = '#session=' + encodeURIComponent(config.socketPath); - const wsUrl = model.wsUrls.get(config.socketPath); +const SessionChip: React.FC<{ descriptor: BrowserDescriptor; wsUrl: string | undefined; visible: boolean; model: SessionModel }> = ({ descriptor, wsUrl, visible, model }) => { + const href = '#session=' + encodeURIComponent(descriptor.pipeName!); const channel = React.useMemo(() => { - if (!canConnect || !visible || !wsUrl) + if (!wsUrl || !visible) return undefined; return DevToolsClient.create(wsUrl); - }, [canConnect, visible, wsUrl]); + }, [wsUrl, visible]); const [selectedTab, setSelectedTab] = React.useState(); @@ -129,28 +127,27 @@ const SessionChip: React.FC<{ sessionFile: SessionFile; canConnect: boolean; vis }; }, [channel]); - const chipTitle = selectedTab ? `[${config.name}] ${selectedTab.url} \u2014 ${selectedTab.title}` : config.name; - const clickable = canConnect && wsUrl !== null; + const chipTitle = selectedTab ? `[${descriptor.title}] ${selectedTab.url} \u2014 ${selectedTab.title}` : descriptor.title; return ( - { + { e.preventDefault(); - if (clickable) + if (wsUrl) navigate(href); }}>
-
+
- {selectedTab ? <>[{config.name}] {selectedTab.url} — {selectedTab.title} : config.name} + {selectedTab ? <>[{descriptor.title}] {selectedTab.url} — {selectedTab.title} : descriptor.title} - {canConnect && ( + {wsUrl && ( )} - {!canConnect && ( + {!wsUrl && (
{channel && } - {!canConnect &&
Session closed
} - {canConnect && !channel && wsUrl === null &&
- Session v{sessionFile.config.version} is not compatible with this viewer{model.clientInfo ? ` v${model.clientInfo.version}` : ''}. -
- Please update playwright-cli and restart this with "playwright-cli show". -
} - {canConnect && !channel && wsUrl === undefined &&
Connecting
} + {!wsUrl &&
Session closed
}
); diff --git a/packages/devtools/src/index.tsx b/packages/devtools/src/index.tsx index 664fbbf93560b..99bd7acc6eaed 100644 --- a/packages/devtools/src/index.tsx +++ b/packages/devtools/src/index.tsx @@ -60,7 +60,7 @@ const App: React.FC = () => { }, []); if (socketPath) { - const wsUrl = model.wsUrls.get(socketPath); + const wsUrl = model.sessionBySocketPath(socketPath)?.wsUrl; return ; } return ; diff --git a/packages/devtools/src/sessionModel.ts b/packages/devtools/src/sessionModel.ts index 57b3e0480a015..f80a373977538 100644 --- a/packages/devtools/src/sessionModel.ts +++ b/packages/devtools/src/sessionModel.ts @@ -14,23 +14,23 @@ * limitations under the License. */ -import type { ClientInfo, SessionFile } from '../../playwright-core/src/cli/client/registry'; +import type { ClientInfo } from '../../playwright-core/src/cli/client/registry'; +import type { BrowserDescriptor } from '../../playwright-core/src/serverRegistry'; export type SessionStatus = { - file: SessionFile; - canConnect: boolean; + browserDescriptor: BrowserDescriptor; + wsUrl?: string; }; + type Listener = () => void; export class SessionModel { sessions: SessionStatus[] = []; - readonly wsUrls: Map = new Map(); clientInfo: ClientInfo | undefined; error: string | undefined; loading = true; - private _knownTimestamps = new Map(); private _pollActive = false; private _pollTimeout: ReturnType | undefined; private _lastJson = ''; @@ -67,7 +67,7 @@ export class SessionModel { } sessionBySocketPath(socketPath: string): SessionStatus | undefined { - return this.sessions.find(s => s.file.config.socketPath === socketPath); + return this.sessions.find(s => s.browserDescriptor.pipeName === socketPath); } private async _fetchSessions() { @@ -84,10 +84,7 @@ export class SessionModel { this.clientInfo = data.clientInfo; this._notify(); - for (const session of this.sessions) { - if (session.canConnect) - this._obtainDevtoolsUrl(session.file); - } + } this.error = undefined; } catch (e: any) { @@ -102,46 +99,24 @@ export class SessionModel { await this._fetchSessions(); } - async closeSession(sessionFile: SessionFile) { + async closeSession(descriptor: BrowserDescriptor) { await fetch('/api/sessions/close', { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ sessionFile }), + body: JSON.stringify({ browserDescriptor: descriptor }), }); await this._fetchSessions(); } - async deleteSessionData(sessionFile: SessionFile) { + async deleteSessionData(descriptor: BrowserDescriptor) { await fetch('/api/sessions/delete-data', { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ sessionFile }), + body: JSON.stringify({ browserDescriptor: descriptor }), }); await this._fetchSessions(); } - private _obtainDevtoolsUrl(sessionFile: SessionFile) { - const { config } = sessionFile; - if (this._knownTimestamps.get(config.socketPath) === config.timestamp) - return; - this._knownTimestamps.set(config.socketPath, config.timestamp); - fetch('/api/sessions/devtools-start', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ sessionFile }), - }).then(async resp => { - if (resp.ok) { - const { url } = await resp.json(); - this.wsUrls.set(config.socketPath, url); - } else { - this.wsUrls.set(config.socketPath, null); - } - this._notify(); - }).catch(() => { - this._knownTimestamps.delete(config.socketPath); - }); - } - dispose() { this.stopPolling(); this._listeners.clear(); diff --git a/packages/playwright-core/src/cli/daemon/program.ts b/packages/playwright-core/src/cli/daemon/program.ts index 77be4232e48ce..7ef2059079e47 100644 --- a/packages/playwright-core/src/cli/daemon/program.ts +++ b/packages/playwright-core/src/cli/daemon/program.ts @@ -52,6 +52,13 @@ export function decorateCLICommand(command: Command, version: string) { const socketPath = await startCliDaemonServer(sessionName, browserContext, mcpConfig, clientInfo, { ...options, exitOnClose: true }); console.log(`### Success\nDaemon listening on ${socketPath}`); console.log(''); + try { + await (browser as any)._startServer(sessionName, { workspaceDir: clientInfo.workspaceDir }); + browserContext.on('close', () => (browser as any)._stopServer().catch(() => {})); + } catch (error) { + if (!error.message.includes('Server is already running')) + throw error; + } } catch (error) { const message = process.env.PWDEBUGIMPL ? (error as Error).stack || (error as Error).message : (error as Error).message; console.log(`### Error\n${message}`); diff --git a/packages/playwright-core/src/client/browserContext.ts b/packages/playwright-core/src/client/browserContext.ts index 45d137c9c9a8d..5aa032103618f 100644 --- a/packages/playwright-core/src/client/browserContext.ts +++ b/packages/playwright-core/src/client/browserContext.ts @@ -589,10 +589,6 @@ export class BrowserContext extends ChannelOwner return; throw new Error(`File access denied: ${filePath} is outside allowed roots. Allowed roots: ${this._allowedDirectories.length ? this._allowedDirectories.join(', ') : 'none'}`); } - - async _devtoolsStart(): Promise<{ url: string }> { - return await this._channel.devtoolsStart(); - } } async function prepareStorageState(platform: Platform, storageState: string | SetStorageState): Promise> { diff --git a/packages/playwright-core/src/client/eventEmitter.ts b/packages/playwright-core/src/client/eventEmitter.ts index 49c38c88aee05..ecb9277019baf 100644 --- a/packages/playwright-core/src/client/eventEmitter.ts +++ b/packages/playwright-core/src/client/eventEmitter.ts @@ -26,6 +26,11 @@ import type { EventEmitter as EventEmitterType } from 'events'; import type { Platform } from './platform'; import type { Disposable } from './disposable'; +type EventEmitterLike = { + on(eventName: string | symbol, handler: (...args: any[]) => unknown): unknown; + removeListener(eventName: string | symbol, handler: (...args: any[]) => unknown): unknown; +}; + type EventType = string | symbol; type Listener = (...args: any[]) => any; type EventMap = Record; @@ -400,9 +405,9 @@ function wrappedListener(l: Listener): Listener { class EventsHelper { static addEventListener( - emitter: EventEmitterType, + emitter: EventEmitterLike, eventName: (string | symbol), - handler: (...args: any[]) => void): Disposable { + handler: (...args: any[]) => any): Disposable { emitter.on(eventName, handler); return { dispose: async () => { emitter.removeListener(eventName, handler); } diff --git a/packages/playwright-core/src/devtools/DEPS.list b/packages/playwright-core/src/devtools/DEPS.list index b422e7cab99f2..3423c19bba5c9 100644 --- a/packages/playwright-core/src/devtools/DEPS.list +++ b/packages/playwright-core/src/devtools/DEPS.list @@ -2,6 +2,7 @@ ../../ ../server/registry/index.ts ../server/utils/ +../serverRegistry.ts ../utils/ -../cli/client/registry.ts -../cli/client/session.ts +../client/connect.ts +../client/eventEmitter.ts diff --git a/packages/playwright-core/src/devtools/devtoolsApp.ts b/packages/playwright-core/src/devtools/devtoolsApp.ts index 948d9cf63ae88..79bbd8df0d975 100644 --- a/packages/playwright-core/src/devtools/devtoolsApp.ts +++ b/packages/playwright-core/src/devtools/devtoolsApp.ts @@ -18,20 +18,20 @@ import fs from 'fs'; import path from 'path'; import os from 'os'; import net from 'net'; - +import http from 'http'; import { chromium } from '../..'; import { HttpServer } from '../server/utils/httpServer'; import { gracefullyProcessExitDoNotHang } from '../server/utils/processLauncher'; import { findChromiumChannelBestEffort, registryDirectory } from '../server/registry/index'; import { calculateSha1 } from '../utils'; -import { createClientInfo, Registry } from '../cli/client/registry'; -import { Session } from '../cli/client/session'; +import { CDPConnection, DevToolsConnection } from './devtoolsController'; +import { serverRegistry } from '../serverRegistry'; +import { connectToBrowserAcrossVersions } from '../client/connect'; -import type http from 'http'; -import type { Page } from '../../types/types'; -import type { ClientInfo, SessionFile } from '../cli/client/registry'; +import type * as api from '../..'; import type { SessionStatus } from '@devtools/sessionModel'; +import type { BrowserDescriptor } from '../serverRegistry'; function readBody(request: http.IncomingMessage): Promise { return new Promise((resolve, reject) => { @@ -49,11 +49,11 @@ function readBody(request: http.IncomingMessage): Promise { }); } -async function parseRequest(request: http.IncomingMessage): Promise<{ sessionFile: SessionFile, args?: any }> { +async function parseRequest(request: http.IncomingMessage): Promise<{ browserDescriptor: BrowserDescriptor }> { const body = await readBody(request); - if (!body.sessionFile) + if (!body.browserDescriptor) throw new Error('Dashboard app is too old, please close it and open again'); - return { sessionFile: body.sessionFile }; + return { browserDescriptor: body.browserDescriptor }; } function sendJSON(response: http.ServerResponse, data: any, statusCode = 200) { @@ -62,76 +62,104 @@ function sendJSON(response: http.ServerResponse, data: any, statusCode = 200) { 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}`); +async function loadBrowserDescriptorSessions(wsPath: string): Promise { + const servers = await serverRegistry.list({ includeDisconnected: true }); + const sessions: SessionStatus[] = []; + for (const [, browsers] of servers) { + for (const browser of browsers) { + let wsUrl: string | undefined; + if (browser.canConnect) { + const url = new URL(wsPath, 'http://localhost'); + url.searchParams.set('browserDescriptor', JSON.stringify(browser)); + wsUrl = url.pathname + url.search; + } + sessions.push({ browserDescriptor: browser, wsUrl }); + } + } + return sessions; +} + +const socketPathToDevToolsConnection = new Map(); + +async function handleApiRequest(httpServer: HttpServer, request: http.IncomingMessage, response: http.ServerResponse) { + const url = new URL(request.url!, httpServer.urlPrefix('human-readable')); const apiPath = url.pathname; if (apiPath === '/api/sessions/list' && request.method === 'GET') { - const registry = await Registry.load(); - const sessions: SessionStatus[] = []; - for (const [, files] of registry.entryMap()) { - for (const file of files) { - const session = new Session(file); - const canConnect = await session.canConnect(); - if (canConnect || file.config.cli.persistent) - sessions.push({ file: file, canConnect }); - } - } - sendJSON(response, { sessions, clientInfo }); + const sessions = await loadBrowserDescriptorSessions(httpServer.wsGuid()!); + sendJSON(response, { sessions }); return; } if (apiPath === '/api/sessions/close' && request.method === 'POST') { - const { sessionFile } = await parseRequest(request); - await new Session(sessionFile).stop(); - sendJSON(response, { success: true }); - return; + const { browserDescriptor } = await parseRequest(request); + let browser: api.Browser; + try { + browser = await connectToBrowserAcrossVersions(browserDescriptor); + } catch (e) { + sendJSON(response, { error: 'Failed to connect to browser socket: ' + e.message }, 500); + return; + } + try { + await Promise.all(browser.contexts().map(context => context.close())); + await browser.close(); + sendJSON(response, { success: true }); + return; + } catch (e) { + sendJSON(response, { error: 'Failed to close browser: ' + e.message }, 500); + return; + } } if (apiPath === '/api/sessions/delete-data' && request.method === 'POST') { - const { sessionFile } = await parseRequest(request); - await new Session(sessionFile).deleteData(); sendJSON(response, { success: true }); return; } - if (apiPath === '/api/sessions/run' && request.method === 'POST') { - const { sessionFile, args } = await parseRequest(request); - if (!args) - throw new Error('Missing "args" parameter'); - const result = await new Session(sessionFile).run(clientInfo, args); - sendJSON(response, { result }); - return; - } - - if (apiPath === '/api/sessions/devtools-start' && request.method === 'POST') { - const { sessionFile } = await parseRequest(request); - const result = await new Session(sessionFile).run(clientInfo, { _: ['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; - } - response.statusCode = 404; response.end(JSON.stringify({ error: 'Not found' })); } -async function openDevToolsApp(): Promise { +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 => { + handleApiRequest(httpServer, request, response).catch(e => { response.statusCode = 500; response.end(JSON.stringify({ error: e.message })); }); return true; }); + httpServer.createWebSocket(url => { + const descriptorJson = url.searchParams.get('browserDescriptor'); + if (!descriptorJson) + throw new Error('Unsupported WebSocket URL: ' + url.toString()); + const browserDescriptor = JSON.parse(descriptorJson) as BrowserDescriptor; + + const cdpPageId = url.searchParams.get('cdpPageId'); + if (cdpPageId) { + const socketPath = browserDescriptor.pipeName!; + const connection = socketPathToDevToolsConnection.get(socketPath); + if (!connection) + throw new Error('CDP connection not found for socket path: ' + socketPath); + const page = connection.pageForId(cdpPageId); + if (!page) + throw new Error('Page not found for page ID: ' + cdpPageId); + return new CDPConnection(page); + } + + const socketPath = browserDescriptor.pipeName!; + const cdpUrl = new URL(httpServer.urlPrefix('human-readable')); + cdpUrl.pathname = httpServer.wsGuid()!; + cdpUrl.searchParams.set('browserDescriptor', descriptorJson); + const connection = new DevToolsConnection(browserDescriptor, cdpUrl, () => socketPathToDevToolsConnection.delete(socketPath)); + socketPathToDevToolsConnection.set(socketPath, connection); + return connection; + }); + 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); @@ -185,7 +213,7 @@ async function launchApp(appName: string) { return { context, page }; } -export async function syncLocalStorageWithSettings(page: Page, appName: string) { +export async function syncLocalStorageWithSettings(page: api.Page, appName: string) { const settingsFile = path.join(registryDirectory, '.settings', `${appName}.json`); await page.exposeBinding('_saveSerializedSettings', (_, settings) => { @@ -222,6 +250,8 @@ function devtoolsSocketPath() { async function acquireSingleton(): Promise { const socketPath = devtoolsSocketPath(); + if (process.platform !== 'win32') + await fs.promises.mkdir(path.dirname(socketPath), { recursive: true }); return await new Promise((resolve, reject) => { const server = net.createServer(); diff --git a/packages/playwright-core/src/devtools/devtoolsController.ts b/packages/playwright-core/src/devtools/devtoolsController.ts new file mode 100644 index 0000000000000..a1c5a45dc94d7 --- /dev/null +++ b/packages/playwright-core/src/devtools/devtoolsController.ts @@ -0,0 +1,347 @@ +/** + * 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 { eventsHelper } from '../client/eventEmitter'; +import { connectToBrowserAcrossVersions } from '../client/connect'; + +import type * as api from '../../types/types'; +import type { Transport } from '../server/utils/httpServer'; +import type { DevToolsChannel, DevToolsChannelEvents, Tab } from '@devtools/devtoolsChannel'; +import type { BrowserDescriptor } from '../serverRegistry'; + +export class DevToolsConnection implements Transport, DevToolsChannel { + readonly version = 1; + + sendEvent?: (method: string, params: any) => void; + close?: () => void; + + selectedPage: api.Page | null = null; + private _lastFrameData: string | null = null; + private _lastViewportSize: { width: number, height: number } | null = null; + private _pageListeners: { dispose: () => Promise }[] = []; + private _contextListeners: { dispose: () => Promise }[] = []; + private _eventListeners = new Map>(); + + private _browserDescriptor: BrowserDescriptor; + private _cdpUrl: URL; + private _onclose: () => void; + + private _initPromise?: Promise; + private _context!: api.BrowserContext; + private _browser?: api.Browser; + + constructor(browserDescriptor: BrowserDescriptor, cdpUrl: URL, onclose: () => void) { + this._browserDescriptor = browserDescriptor; + this._cdpUrl = cdpUrl; + this._onclose = onclose; + } + + on(event: K, listener: (params: DevToolsChannelEvents[K]) => void): void { + let set = this._eventListeners.get(event); + if (!set) { + set = new Set(); + this._eventListeners.set(event, set); + } + set.add(listener); + } + + off(event: K, listener: (params: DevToolsChannelEvents[K]) => void): void { + this._eventListeners.get(event)?.delete(listener); + } + + private _emit(event: K, params: DevToolsChannelEvents[K]): void { + this.sendEvent?.(event, params); + const set = this._eventListeners.get(event); + if (set) { + for (const fn of set) + fn(params); + } + } + + onconnect() { + this._initPromise = this._init(); + this._initPromise.catch(() => this.close?.()); + } + + private async _init() { + this._browser = await connectToBrowserAcrossVersions(this._browserDescriptor); + this._context = this._browser.contexts()[0]; + + this._contextListeners.push( + eventsHelper.addEventListener(this._context, 'page', page => { + this._sendTabList(); + if (!this.selectedPage) + this._selectPage(page); + }), + ); + + // Auto-select first page. + const pages = this._context.pages(); + if (pages.length > 0) + this._selectPage(pages[0]); + + this._sendCachedState(); + } + + onclose() { + this._deselectPage(); + this._contextListeners.forEach(d => d.dispose()); + this._contextListeners = []; + this._onclose(); + } + + async dispatch(method: string, params: any): Promise { + await this._initPromise; + return (this as any)[method]?.(params); + } + + async selectTab(params: { pageId: string }) { + const page = this._context.pages().find(p => this._pageId(p) === params.pageId); + if (page) + await this._selectPage(page); + } + + async closeTab(params: { pageId: string }) { + const page = this._context.pages().find(p => this._pageId(p) === params.pageId); + if (page) + await page.close({ reason: 'Closed from devtools' }); + } + + async newTab() { + const page = await this._context.newPage(); + await this._selectPage(page); + } + + async navigate(params: { url: string }) { + if (!this.selectedPage || !params.url) + return; + const page = this.selectedPage; + await page.goto(params.url); + } + + async back() { + await this.selectedPage?.goBack(); + } + + async forward() { + await this.selectedPage?.goForward(); + } + + async reload() { + await this.selectedPage?.reload(); + } + + async mousemove(params: { x: number; y: number }) { + await this.selectedPage?.mouse.move(params.x, params.y); + } + + async mousedown(params: { x: number; y: number; button?: 'left' | 'right' | 'middle' }) { + await this.selectedPage?.mouse.move(params.x, params.y); + await this.selectedPage?.mouse.down({ button: params.button || 'left' }); + } + + async mouseup(params: { x: number; y: number; button?: 'left' | 'right' | 'middle' }) { + await this.selectedPage?.mouse.move(params.x, params.y); + await this.selectedPage?.mouse.up({ button: params.button || 'left' }); + } + + async wheel(params: { deltaX: number; deltaY: number }) { + await this.selectedPage?.mouse.wheel(params.deltaX, params.deltaY); + } + + async keydown(params: { key: string }) { + await this.selectedPage?.keyboard.down(params.key); + } + + async keyup(params: { key: string }) { + await this.selectedPage?.keyboard.up(params.key); + } + + private async _selectPage(page: api.Page) { + if (this.selectedPage === page) + return; + + if (this.selectedPage) { + this._pageListeners.forEach(d => d.dispose()); + this._pageListeners = []; + await this.selectedPage.inspector().stopScreencast(); + } + + this.selectedPage = page; + this._lastFrameData = null; + this._lastViewportSize = null; + this._sendTabList(); + + this._pageListeners.push( + eventsHelper.addEventListener(page, 'close', () => { + this._deselectPage(); + const pages = page.context().pages(); + if (pages.length > 0) + this._selectPage(pages[0]); + this._sendTabList(); + }), + eventsHelper.addEventListener(page, 'framenavigated', frame => { + if (frame === page.mainFrame()) + this._sendTabList(); + }), + eventsHelper.addEventListener(page.inspector(), 'screencastframe', ({ data }) => this._writeFrame(data, page.viewportSize()?.width ?? 0, page.viewportSize()?.height ?? 0)) + ); + + const maxSize = { width: 1280, height: 800 }; + await page.inspector().startScreencast({ maxSize }); + } + + private _deselectPage() { + if (!this.selectedPage) + return; + this._pageListeners.forEach(d => d.dispose()); + this._pageListeners = []; + this.selectedPage.inspector().stopScreencast().catch(() => {}); + this.selectedPage = null; + this._lastFrameData = null; + this._lastViewportSize = null; + } + + async pickLocator() { + if (!this.selectedPage) + return; + const locator = await this.selectedPage.inspector().pickLocator(); + this._emit('elementPicked', { selector: locator.toString() }); + } + + async cancelPickLocator() { + await this.selectedPage?.inspector().cancelPickLocator(); + } + + private _sendCachedState() { + if (this._lastFrameData && this._lastViewportSize) + this._emit('frame', { data: this._lastFrameData, viewportWidth: this._lastViewportSize.width, viewportHeight: this._lastViewportSize.height }); + this._sendTabList(); + } + + async tabs(): Promise<{ tabs: Tab[] }> { + return { tabs: await this._tabList() }; + } + + private async _tabList(): Promise { + const pages = this._context.pages(); + if (pages.length === 0) + return []; + const devtoolsUrl = await this._devtoolsUrl(pages[0]); + return await Promise.all(pages.map(async page => ({ + pageId: this._pageId(page), + title: await page.title() || page.url(), + url: page.url(), + selected: page === this.selectedPage, + inspectorUrl: devtoolsUrl ? await this._pageInspectorUrl(page, devtoolsUrl) : 'data:text/plain,DevTools only supported in Chromium based browsers', + }))); + } + + pageForId(pageId: string) { + return this._context?.pages().find(p => this._pageId(p) === pageId); + } + + private _pageId(p: api.Page): string { + return (p as any)._guid; + } + + private async _devtoolsUrl(page: api.Page) { + const cdpPort = this._browserDescriptor.browser.launchOptions.cdpPort; + if (cdpPort) + return new URL(`http://localhost:${cdpPort}/devtools/`); + + const browserRevision = await getBrowserRevision(page); + if (!browserRevision) + return null; + return new URL(`https://chrome-devtools-frontend.appspot.com/serve_rev/${browserRevision}/`); + } + + private async _pageInspectorUrl(page: api.Page, devtoolsUrl: URL): Promise { + const inspector = new URL('./devtools_app.html', devtoolsUrl); + const cdp = new URL(this._cdpUrl); + cdp.searchParams.set('cdpPageId', this._pageId(page)); + inspector.searchParams.set('ws', `${cdp.host}${cdp.pathname}${cdp.search}`); + const url = inspector.toString(); + return url; + } + + private _sendTabList() { + this._tabList().then(tabs => this._emit('tabs', { tabs })); + } + + private _writeFrame(frame: Buffer, viewportWidth: number, viewportHeight: number) { + const data = frame.toString('base64'); + this._lastFrameData = data; + this._lastViewportSize = { width: viewportWidth, height: viewportHeight }; + this._emit('frame', { data, viewportWidth, viewportHeight }); + } +} + +async function getBrowserRevision(page: api.Page): Promise { + try { + const session = await page.context().newCDPSession(page); + const version = await session.send('Browser.getVersion'); + await session.detach(); + return version.revision; + } catch (error) { + return null; + } +} + +export class CDPConnection implements Transport { + sendEvent?: (method: string, params: any) => void; + close?: () => void; + + private _page: api.Page; + private _rawSession: api.CDPSession | null = null; + private _rawSessionListeners: { dispose: () => Promise }[] = []; + private _initializePromise: Promise | undefined; + + constructor(page: api.Page) { + this._page = page; + } + + onconnect() { + this._initializePromise = this._initializeRawSession(); + } + + async dispatch(method: string, params: any): Promise { + await this._initializePromise; + if (!this._rawSession) + throw new Error('CDP session is not initialized'); + return await this._rawSession.send(method as any, params); + } + + onclose() { + this._rawSessionListeners.forEach(listener => listener.dispose()); + this._rawSession?.detach().catch(() => {}); + this._rawSession = null; + this._initializePromise = undefined; + } + + private async _initializeRawSession() { + const session = await this._page.context().newCDPSession(this._page); + this._rawSession = session; + this._rawSessionListeners = [ + eventsHelper.addEventListener(session, 'event', ({ method, params }) => { + this.sendEvent?.(method, params); + }), + eventsHelper.addEventListener(session, 'close', () => { + this.close?.(); + }), + ]; + } +} diff --git a/packages/playwright-core/src/protocol/validator.ts b/packages/playwright-core/src/protocol/validator.ts index 2eea4cb028581..b841a21e8923b 100644 --- a/packages/playwright-core/src/protocol/validator.ts +++ b/packages/playwright-core/src/protocol/validator.ts @@ -1182,10 +1182,6 @@ scheme.BrowserContextClockSetSystemTimeParams = tObject({ timeString: tOptional(tString), }); scheme.BrowserContextClockSetSystemTimeResult = tOptional(tObject({})); -scheme.BrowserContextDevtoolsStartParams = tOptional(tObject({})); -scheme.BrowserContextDevtoolsStartResult = tObject({ - url: tString, -}); scheme.PageInitializer = tObject({ mainFrame: tChannel(['Frame']), viewportSize: tOptional(tObject({ diff --git a/packages/playwright-core/src/server/browser.ts b/packages/playwright-core/src/server/browser.ts index f8ea207d682c5..f2a1af5d08f2c 100644 --- a/packages/playwright-core/src/server/browser.ts +++ b/packages/playwright-core/src/server/browser.ts @@ -26,7 +26,6 @@ import { Page } from './page'; import { ClientCertificatesProxy } from './socksClientCertificatesInterceptor'; import { PlaywrightPipeServer } from '../remote/playwrightPipeServer'; import { PlaywrightWebSocketServer } from '../remote/playwrightWebSocketServer'; -import { createGuid } from './utils/crypto'; import { serverRegistry } from '../serverRegistry'; import type * as types from './types'; @@ -221,6 +220,7 @@ export class BrowserServer { async start(title: string, options: { workspaceDir?: string, wsPath?: string }): Promise<{ wsEndpoint?: string, pipeName?: string }> { if (this._isStarted) throw new Error(`Server is already started.`); + this._isStarted = true; const result: { wsEndpoint?: string, pipeName?: string } = {}; this._pipeServer = new PlaywrightPipeServer(this._browser); @@ -229,9 +229,9 @@ export class BrowserServer { result.pipeName = this._pipeSocketPath; if (options.wsPath) { - const path = options.wsPath ? (options.wsPath.startsWith('/') ? options.wsPath : `/${options.wsPath}`) : `/${createGuid()}`; + const path = options.wsPath.startsWith('/') ? options.wsPath : `/${options.wsPath}`; this._wsServer = new PlaywrightWebSocketServer(this._browser, path); - result.wsEndpoint = await this._wsServer.listen(0); + result.wsEndpoint = await this._wsServer.listen(0, 'localhost', path); } await serverRegistry.create(this._browser, { @@ -251,6 +251,7 @@ export class BrowserServer { await this._wsServer?.close(); this._pipeServer = undefined; this._wsServer = undefined; + this._isStarted = false; } private async _socketPath() { diff --git a/packages/playwright-core/src/server/browserContext.ts b/packages/playwright-core/src/server/browserContext.ts index 6fd0c6e8f18f2..7d9cfe12a52da 100644 --- a/packages/playwright-core/src/server/browserContext.ts +++ b/packages/playwright-core/src/server/browserContext.ts @@ -35,7 +35,6 @@ import { Page, PageBinding } from './page'; import { RecorderApp } from './recorder/recorderApp'; import { Selectors } from './selectors'; import { Tracing } from './trace/recorder/tracing'; -import { DevToolsController } from './devtoolsController'; import * as rawStorageSource from '../generated/storageScriptSource'; import type { Artifact } from './artifact'; @@ -119,7 +118,6 @@ export abstract class BrowserContext extends Sdk private _playwrightBindingExposed?: Promise; readonly dialogManager: DialogManager; private _consoleApiExposed = false; - private _devtools: DevToolsController; constructor(browser: Browser, options: types.BrowserContextOptions, browserContextId: string | undefined) { super(browser, 'browser-context'); @@ -130,7 +128,6 @@ export abstract class BrowserContext extends Sdk this._isPersistentContext = !browserContextId; this._closePromise = new Promise(fulfill => this._closePromiseFulfill = fulfill); this._selectors = new Selectors(options.selectorEngines || [], options.testIdAttributeName); - this._devtools = new DevToolsController(this); this.fetchRequest = new BrowserContextAPIRequestContext(this); this.tracing = new Tracing(this, browser.options.tracesDir); @@ -511,11 +508,6 @@ export abstract class BrowserContext extends Sdk await this.doUpdateRequestInterception(); } - async devtoolsStart(): Promise { - const size = validateVideoSize(undefined, undefined); - return await this._devtools.start({ width: size.width, height: size.height, quality: 90 }); - } - isClosingOrClosed() { return this._closedStatus !== 'open'; } @@ -539,8 +531,6 @@ export abstract class BrowserContext extends Sdk this.emit(BrowserContext.Events.BeforeClose); this._closedStatus = 'closing'; - await this._devtools.dispose(); - for (const harRecorder of this._harRecorders.values()) await harRecorder.flush(); await this.tracing.flush(); diff --git a/packages/playwright-core/src/server/devtoolsController.ts b/packages/playwright-core/src/server/devtoolsController.ts deleted file mode 100644 index ed431eea0ae76..0000000000000 --- a/packages/playwright-core/src/server/devtoolsController.ts +++ /dev/null @@ -1,404 +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 { createGuid, eventsHelper } from '../utils'; -import { HttpServer } from './utils/httpServer'; -import { BrowserContext } from './browserContext'; -import { Page } from './page'; -import { ProgressController } from './progress'; -import { Recorder, RecorderEvent } from './recorder'; -import { CRPage } from './chromium/crPage'; -import { CDPSession } from './chromium/crConnection'; -import { CRBrowserContext } from './chromium/crBrowser'; - -import type { RegisteredListener } from '../utils'; -import type { Transport } from './utils/httpServer'; -import type { CRBrowser } from './chromium/crBrowser'; -import type { ElementInfo } from '@recorder/recorderTypes'; -import type { ScreencastListener } from './screencast'; -import type { DevToolsChannel, DevToolsChannelEvents, Tab } from '@devtools/devtoolsChannel'; - -export class DevToolsController { - private _context: BrowserContext; - private _url: string | undefined; - private _httpServer: HttpServer | undefined; - - constructor(context: BrowserContext) { - this._context = context; - } - - async start(options: { width: number, height: number, quality: number, port?: number, host?: string }): Promise { - if (!this._url) { - const guid = createGuid(); - this._httpServer = new HttpServer(); - this._httpServer.createWebSocket(url => { - if (url.searchParams.has('cdp')) - return new CDPConnection(this._context, url.searchParams.get('cdp')!); - return new DevToolsConnection(this._context, this._url!); - }, guid); - await this._httpServer.start({ port: options.port, host: options.host }); - this._url = (this._httpServer.urlPrefix('human-readable') + `/${guid}`).replace('http://', 'ws://'); - } - return this._url; - } - - async dispose() { - await this._httpServer?.stop(); - } -} - -class DevToolsConnection implements Transport, DevToolsChannel { - readonly version = 1; - - sendEvent?: (method: string, params: any) => void; - close?: () => void; - - selectedPage: Page | null = null; - private _lastFrameData: string | null = null; - private _lastViewportSize: { width: number, height: number } | null = null; - private _screencastFrameListener: ScreencastListener | null = null; - private _contextListeners: RegisteredListener[] = []; - private _recorderListeners: RegisteredListener[] = []; - private _context: BrowserContext; - private _controllerUrl: string; - private _recorder: Recorder | null = null; - private _eventListeners = new Map>(); - - constructor(context: BrowserContext, controllerUrl: string) { - this._context = context; - this._controllerUrl = controllerUrl; - } - - on(event: K, listener: (params: DevToolsChannelEvents[K]) => void): void { - let set = this._eventListeners.get(event); - if (!set) { - set = new Set(); - this._eventListeners.set(event, set); - } - set.add(listener); - } - - off(event: K, listener: (params: DevToolsChannelEvents[K]) => void): void { - this._eventListeners.get(event)?.delete(listener); - } - - private _emit(event: K, params: DevToolsChannelEvents[K]): void { - this.sendEvent?.(event, params); - const set = this._eventListeners.get(event); - if (set) { - for (const fn of set) - fn(params); - } - } - - onconnect() { - const context = this._context; - - this._contextListeners.push( - eventsHelper.addEventListener(context, BrowserContext.Events.Page, (page: Page) => { - this._sendTabList(); - if (!this.selectedPage) - this._selectPage(page); - }), - eventsHelper.addEventListener(context, BrowserContext.Events.PageClosed, (page: Page) => { - if (this.selectedPage === page) { - this._deselectPage(); - const pages = context.pages(); - if (pages.length > 0) - this._selectPage(pages[0]); - } - this._sendTabList(); - }), - eventsHelper.addEventListener(context, BrowserContext.Events.InternalFrameNavigatedToNewDocument, (frame, page) => { - if (frame === page.mainFrame()) - this._sendTabList(); - }), - ); - - // Auto-select first page. - const pages = context.pages(); - if (pages.length > 0) - this._selectPage(pages[0]); - - this._sendCachedState(); - } - - onclose() { - this._cancelPicking(); - this._deselectPage(); - eventsHelper.removeEventListeners(this._contextListeners); - this._contextListeners = []; - } - - async dispatch(method: string, params: any): Promise { - return (this as any)[method]?.(params); - } - - async selectTab(params: { pageId: string }) { - const page = this._context.pages().find(p => p.guid === params.pageId); - if (page) - await this._selectPage(page); - } - - async closeTab(params: { pageId: string }) { - const page = this._context.pages().find(p => p.guid === params.pageId); - if (page) - await page.close({ reason: 'Closed from devtools' }); - } - - async newTab() { - await ProgressController.runInternalTask(async progress => { - const page = await this._context.newPage(progress); - await this._selectPage(page); - }); - } - - async navigate(params: { url: string }) { - if (!this.selectedPage || !params.url) - return; - const page = this.selectedPage; - await ProgressController.runInternalTask(async progress => { await page.mainFrame().goto(progress, params.url); }); - } - - async back() { - if (!this.selectedPage) - return; - const page = this.selectedPage; - await ProgressController.runInternalTask(async progress => { await page.goBack(progress, {}); }); - } - - async forward() { - if (!this.selectedPage) - return; - const page = this.selectedPage; - await ProgressController.runInternalTask(async progress => { await page.goForward(progress, {}); }); - } - - async reload() { - if (!this.selectedPage) - return; - const page = this.selectedPage; - await ProgressController.runInternalTask(async progress => { await page.reload(progress, {}); }); - } - - async mousemove(params: { x: number; y: number }) { - if (!this.selectedPage) - return; - const page = this.selectedPage; - await ProgressController.runInternalTask(async progress => { await page.mouse.move(progress, params.x, params.y); }); - } - - async mousedown(params: { x: number; y: number; button?: 'left' | 'right' | 'middle' }) { - if (!this.selectedPage) - return; - const page = this.selectedPage; - await ProgressController.runInternalTask(async progress => { await page.mouse.move(progress, params.x, params.y); await page.mouse.down(progress, { button: params.button || 'left' }); }); - } - - async mouseup(params: { x: number; y: number; button?: 'left' | 'right' | 'middle' }) { - if (!this.selectedPage) - return; - const page = this.selectedPage; - await ProgressController.runInternalTask(async progress => { await page.mouse.move(progress, params.x, params.y); await page.mouse.up(progress, { button: params.button || 'left' }); }); - } - - async wheel(params: { deltaX: number; deltaY: number }) { - if (!this.selectedPage) - return; - const page = this.selectedPage; - await ProgressController.runInternalTask(async progress => { await page.mouse.wheel(progress, params.deltaX, params.deltaY); }); - } - - async keydown(params: { key: string }) { - if (!this.selectedPage) - return; - const page = this.selectedPage; - await ProgressController.runInternalTask(async progress => { await page.keyboard.down(progress, params.key); }); - } - - async keyup(params: { key: string }) { - if (!this.selectedPage) - return; - const page = this.selectedPage; - await ProgressController.runInternalTask(async progress => { await page.keyboard.up(progress, params.key); }); - } - - private async _selectPage(page: Page) { - if (this.selectedPage === page) - return; - - if (this.selectedPage) { - await this.selectedPage.screencast.stopScreencast(this._screencastFrameListener!); - this._screencastFrameListener = null; - } - - this.selectedPage = page; - this._lastFrameData = null; - this._lastViewportSize = null; - this._sendTabList(); - - this._screencastFrameListener = frame => this._writeFrame(frame.buffer, frame.viewportWidth, frame.viewportHeight); - await page.screencast.startScreencast(this._screencastFrameListener, { width: 1280, height: 800, quality: 90 }); - } - - private async _deselectPage() { - if (!this.selectedPage) - return; - const promises = []; - promises.push(this._cancelPicking()); - const screencastFrameListener = this._screencastFrameListener!; - this._screencastFrameListener = null; - promises.push(this.selectedPage.screencast.stopScreencast(screencastFrameListener)); - this.selectedPage = null; - this._lastFrameData = null; - this._lastViewportSize = null; - await Promise.all(promises); - } - - async pickLocator() { - await this._cancelPicking(); - const recorder = await Recorder.forContext(this._context, { omitCallTracking: true, hideToolbar: true }); - this._recorder = recorder; - this._recorderListeners.push( - eventsHelper.addEventListener(recorder, RecorderEvent.ElementPicked, (elementInfo: ElementInfo) => { - this._emit('elementPicked', { selector: elementInfo.selector }); - this._cancelPicking(); - }), - ); - await recorder.setMode('inspecting'); - } - - async cancelPickLocator() { - await this._cancelPicking(); - } - - private async _cancelPicking() { - eventsHelper.removeEventListeners(this._recorderListeners); - this._recorderListeners = []; - if (this._recorder) { - await this._recorder.setMode('none'); - this._recorder = null; - } - } - - private _sendCachedState() { - if (this._lastFrameData && this._lastViewportSize) - this._emit('frame', { data: this._lastFrameData, viewportWidth: this._lastViewportSize.width, viewportHeight: this._lastViewportSize.height }); - this._sendTabList(); - } - - async tabs(): Promise<{ tabs: Tab[] }> { - return { tabs: await this._tabList() }; - } - - private async _tabList(): Promise { - return await Promise.all(this._context.pages().map(async page => ({ - pageId: page.guid, - title: await page.mainFrame().title().catch(() => '') || page.mainFrame().url(), - url: page.mainFrame().url(), - selected: page === this.selectedPage, - inspectorUrl: this._inspectorUrl(page), - }))); - } - - private _devtoolsURL() { - if (this._context._browser.options.wsEndpoint) { - const url = new URL('/devtools/', this._context._browser.options.wsEndpoint); - if (url.protocol === 'ws:') - url.protocol = 'http:'; - if (url.protocol === 'wss:') - url.protocol = 'https:'; - return url; - } - - return new URL(`https://chrome-devtools-frontend.appspot.com/serve_rev/@${(this._context._browser as CRBrowser)._revision}/`); - } - - private _inspectorUrl(page: Page): string | undefined { - if (!(page.delegate instanceof CRPage)) - return; - const inspector = new URL('./devtools_app.html', this._devtoolsURL()); - const cdp = new URL(this._controllerUrl); - cdp.searchParams.set('cdp', page.guid); - inspector.searchParams.set('ws', `${cdp.host}${cdp.pathname}${cdp.search}`); - return inspector.toString(); - } - - private _sendTabList() { - this._tabList().then(tabs => this._emit('tabs', { tabs })); - } - - private _writeFrame(frame: Buffer, viewportWidth: number, viewportHeight: number) { - const data = frame.toString('base64'); - this._lastFrameData = data; - this._lastViewportSize = { width: viewportWidth, height: viewportHeight }; - this._emit('frame', { data, viewportWidth, viewportHeight }); - } -} - -class CDPConnection implements Transport { - sendEvent?: (method: string, params: any) => void; - close?: () => void; - - private _context: BrowserContext; - private _pageId: string; - private _rawSession: CDPSession | null = null; - private _rawSessionListeners: RegisteredListener[] = []; - private _initializePromise: Promise | undefined; - - constructor(context: BrowserContext, pageId: string) { - this._context = context; - this._pageId = pageId; - } - - onconnect() { - this._initializePromise = this._initializeRawSession(this._pageId); - } - - async dispatch(method: string, params: any): Promise { - await this._initializePromise; - if (!this._rawSession) - throw new Error('CDP session is not initialized'); - return await this._rawSession.send(method, params); - } - - onclose() { - eventsHelper.removeEventListeners(this._rawSessionListeners); - if (this._rawSession) - void this._rawSession.detach().catch(() => {}); - this._rawSession = null; - this._initializePromise = undefined; - } - - private async _initializeRawSession(pageId: string) { - const page = this._context.pages().find(p => p.guid === pageId); - if (!page) { - this.close?.(); - return; - } - const crContext = this._context as CRBrowserContext; - const session = await crContext.newCDPSession(page); - this._rawSession = session; - this._rawSessionListeners = [ - eventsHelper.addEventListener(session, CDPSession.Events.Event, (event: { method: string, params?: any }) => { - this.sendEvent?.(event.method, event.params); - }), - eventsHelper.addEventListener(session, CDPSession.Events.Closed, () => { - this.close?.(); - }), - ]; - } -} diff --git a/packages/playwright-core/src/server/dispatchers/browserContextDispatcher.ts b/packages/playwright-core/src/server/dispatchers/browserContextDispatcher.ts index b9af6b5d33508..5a60a15b992d0 100644 --- a/packages/playwright-core/src/server/dispatchers/browserContextDispatcher.ts +++ b/packages/playwright-core/src/server/dispatchers/browserContextDispatcher.ts @@ -404,11 +404,6 @@ export class BrowserContextDispatcher extends Dispatcher { - const url = await this._context.devtoolsStart(); - return { url }; - } - async updateSubscription(params: channels.BrowserContextUpdateSubscriptionParams, progress: Progress): Promise { if (params.enabled) this._subscriptions.add(params.event); diff --git a/packages/playwright-core/src/serverRegistry.ts b/packages/playwright-core/src/serverRegistry.ts index 8a1784a05080c..b13e743682ab1 100644 --- a/packages/playwright-core/src/serverRegistry.ts +++ b/packages/playwright-core/src/serverRegistry.ts @@ -41,13 +41,12 @@ export type BrowserDescriptor = BrowserInfo & { }; }; -type BrowserEntry = BrowserDescriptor & { - canConnect: boolean; - file: string; -}; +export type BrowserStatus = BrowserDescriptor & { canConnect: boolean }; + +type BrowserEntry = BrowserStatus & { file: string }; class ServerRegistry { - async list(options?: { gc?: boolean }): Promise> { + async list(options?: { gc?: boolean, includeDisconnected?: boolean }): Promise> { const files = await fs.promises.readdir(this._browsersDir()).catch(() => []); const result = new Map[]>(); for (const file of files) { @@ -66,7 +65,7 @@ class ServerRegistry { } } - const resolvedResult = new Map(); + const resolvedResult = new Map(); for (const [key, promises] of result) { const entries = await Promise.all(promises); if (options?.gc) { @@ -75,7 +74,7 @@ class ServerRegistry { await fs.promises.unlink(entry.file).catch(() => {}); } } - const list = entries.filter(entry => entry.canConnect); + const list = options?.includeDisconnected ? entries : entries.filter(entry => entry.canConnect); if (list.length) resolvedResult.set(key, list); } diff --git a/packages/playwright-core/src/utils/isomorphic/protocolMetainfo.ts b/packages/playwright-core/src/utils/isomorphic/protocolMetainfo.ts index eecd0a029fbea..fb35b02047e26 100644 --- a/packages/playwright-core/src/utils/isomorphic/protocolMetainfo.ts +++ b/packages/playwright-core/src/utils/isomorphic/protocolMetainfo.ts @@ -105,7 +105,6 @@ export const methodMetainfo = new Map; clockSetFixedTime(params: BrowserContextClockSetFixedTimeParams, progress?: Progress): Promise; clockSetSystemTime(params: BrowserContextClockSetSystemTimeParams, progress?: Progress): Promise; - devtoolsStart(params?: BrowserContextDevtoolsStartParams, progress?: Progress): Promise; } export type BrowserContextBindingCallEvent = { binding: BindingCallChannel, @@ -2059,11 +2058,6 @@ export type BrowserContextClockSetSystemTimeOptions = { timeString?: string, }; export type BrowserContextClockSetSystemTimeResult = void; -export type BrowserContextDevtoolsStartParams = {}; -export type BrowserContextDevtoolsStartOptions = {}; -export type BrowserContextDevtoolsStartResult = { - url: string, -}; export interface BrowserContextEvents { 'bindingCall': BrowserContextBindingCallEvent; diff --git a/packages/protocol/src/protocol.yml b/packages/protocol/src/protocol.yml index d5254a0af52a1..5f94b100ddbd9 100644 --- a/packages/protocol/src/protocol.yml +++ b/packages/protocol/src/protocol.yml @@ -1500,11 +1500,6 @@ BrowserContext: timeNumber: float? timeString: string? - devtoolsStart: - internal: true - returns: - url: string - events: bindingCall: From 67256a43a0dc43b1eb241cb10f0f231c1fbd02d2 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 9 Mar 2026 18:00:34 -0700 Subject: [PATCH 3/5] chore(deps): bump the actions group with 2 updates (#39580) --- .github/workflows/publish_release_docker.yml | 2 +- .github/workflows/tests_bidi.yml | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/publish_release_docker.yml b/.github/workflows/publish_release_docker.yml index 464284537242e..0626c6bc73845 100644 --- a/.github/workflows/publish_release_docker.yml +++ b/.github/workflows/publish_release_docker.yml @@ -24,7 +24,7 @@ jobs: node-version: 20 registry-url: 'https://registry.npmjs.org' - name: Set up Docker QEMU for arm64 docker builds - uses: docker/setup-qemu-action@v3 + uses: docker/setup-qemu-action@v4 with: platforms: arm64 - run: npm ci diff --git a/.github/workflows/tests_bidi.yml b/.github/workflows/tests_bidi.yml index 7ab656dbda8ea..6dcdf52b61861 100644 --- a/.github/workflows/tests_bidi.yml +++ b/.github/workflows/tests_bidi.yml @@ -62,7 +62,7 @@ jobs: PWTEST_USE_BIDI_EXPECTATIONS: ${{ matrix.isPullRequest && '1' || '' }} - name: Upload csv report to GitHub if: ${{ !cancelled() }} - uses: actions/upload-artifact@v6 + uses: actions/upload-artifact@v7 with: name: csv-report-${{ matrix.channel }} path: test-results/report.csv @@ -70,7 +70,7 @@ jobs: - name: Upload json report to GitHub if: ${{ !cancelled() }} - uses: actions/upload-artifact@v6 + uses: actions/upload-artifact@v7 with: name: json-report-${{ matrix.channel }} path: test-results/report.json From e5e81169541a55a30e304a3f71daeaba08345582 Mon Sep 17 00:00:00 2001 From: Pavel Feldman Date: Mon, 9 Mar 2026 20:22:07 -0700 Subject: [PATCH 4/5] fix(cli): consider provided userDataDir a persistent session (#39578) --- .../playwright-core/src/cli/daemon/program.ts | 15 ++++---- packages/playwright-core/src/mcp/config.ts | 6 ++-- tests/mcp/cli-config.spec.ts | 15 +------- tests/mcp/cli-persistent.spec.ts | 34 +++++++++++++++++++ tests/mcp/http.spec.ts | 2 +- tests/mcp/sse.spec.ts | 2 +- 6 files changed, 48 insertions(+), 26 deletions(-) create mode 100644 tests/mcp/cli-persistent.spec.ts diff --git a/packages/playwright-core/src/cli/daemon/program.ts b/packages/playwright-core/src/cli/daemon/program.ts index 7ef2059079e47..5ce7ceefa1151 100644 --- a/packages/playwright-core/src/cli/daemon/program.ts +++ b/packages/playwright-core/src/cli/daemon/program.ts @@ -49,7 +49,8 @@ export function decorateCLICommand(command: Command, version: string) { try { const browser = await createBrowser(mcpConfig, mcpClientInfo); const browserContext = mcpConfig.browser.isolated ? await browser.newContext(mcpConfig.browser.contextOptions) : browser.contexts()[0]; - const socketPath = await startCliDaemonServer(sessionName, browserContext, mcpConfig, clientInfo, { ...options, exitOnClose: true }); + const persistent = options.persistent || options.profile || mcpConfig.browser.userDataDir ? true : undefined; + const socketPath = await startCliDaemonServer(sessionName, browserContext, mcpConfig, clientInfo, { persistent, exitOnClose: true }); console.log(`### Success\nDaemon listening on ${socketPath}`); console.log(''); try { @@ -79,13 +80,9 @@ export async function resolveCLIConfig(clientInfo: ClientInfo, sessionName: stri } catch { } - if (!options.persistent && options.profile) - options.persistent = true; - const daemonOverrides = configUtils.configFromCLIOptions({ config: options.config, browser: options.browser, - isolated: options.persistent === true ? false : undefined, headless: options.headed ? false : undefined, extension: options.extension, userDataDir: options.profile, @@ -102,8 +99,7 @@ export async function resolveCLIConfig(clientInfo: ClientInfo, sessionName: stri browser: { launchOptions: { headless: true, - }, - isolated: true, + } } }); @@ -111,7 +107,10 @@ export async function resolveCLIConfig(clientInfo: ClientInfo, sessionName: stri result = configUtils.mergeConfig(result, daemonOverrides); result = configUtils.mergeConfig(result, envOverrides); - if (!result.extension && !result.browser.userDataDir) { + if (result.browser.isolated === undefined) + result.browser.isolated = !options.profile && !options.persistent && !result.browser.userDataDir; + + if (!result.extension && !result.browser.isolated && !result.browser.userDataDir) { // No custom value provided, use the daemon data dir. const browserToken = result.browser.launchOptions?.channel ?? result.browser?.browserName; const userDataDir = path.resolve(clientInfo.daemonProfilesDir, `ud-${sessionName}-${browserToken}`); diff --git a/packages/playwright-core/src/mcp/config.ts b/packages/playwright-core/src/mcp/config.ts index 67a8a21be8f2c..495d2d9a911a4 100644 --- a/packages/playwright-core/src/mcp/config.ts +++ b/packages/playwright-core/src/mcp/config.ts @@ -85,7 +85,6 @@ export const defaultConfig: FullConfig = { contextOptions: { viewport: null, }, - isolated: false, }, server: {}, timeouts: { @@ -135,6 +134,9 @@ export async function validateConfig(config: FullConfig): Promise { config.browser.launchOptions.chromiumSandbox = true; } + if (config.browser.isolated && config.browser.userDataDir) + throw new Error('Browser userDataDir is not supported in isolated mode.'); + if (config.browser.initScript) { for (const script of config.browser.initScript) { if (!await fileExistsAsync(script)) @@ -339,7 +341,7 @@ export function mergeConfig(base: FullConfig, overrides: Config): FullConfig { ...pickDefined(base.browser), ...pickDefined(overrides.browser), browserName: overrides.browser?.browserName ?? base.browser?.browserName ?? 'chromium', - isolated: overrides.browser?.isolated ?? base.browser?.isolated ?? false, + isolated: overrides.browser?.isolated ?? base.browser?.isolated, launchOptions: { ...pickDefined(base.browser?.launchOptions), ...pickDefined(overrides.browser?.launchOptions), diff --git a/tests/mcp/cli-config.spec.ts b/tests/mcp/cli-config.spec.ts index af44c0f75d3fd..e438b2601824d 100644 --- a/tests/mcp/cli-config.spec.ts +++ b/tests/mcp/cli-config.spec.ts @@ -63,29 +63,16 @@ test('config-print prints merged config from file, env and cli', async ({ cli, s }; await fs.promises.writeFile(testInfo.outputPath('.playwright', 'cli.config.json'), JSON.stringify(fileConfig, null, 2)); - // Env var overrides navigation timeout (30000 from file → 45000 from env). const env = { PLAYWRIGHT_MCP_TIMEOUT_NAVIGATION: '45000' }; - - // CLI arg: --in-memory sets browser.isolated = true. - await cli('open', '--in-memory', server.PREFIX, { env }); - - // Query the resolved config from the running daemon. + await cli('open', server.PREFIX, { env }); const { output } = await cli('config-print', { env }); const configBegin = output.indexOf('{'); expect(configBegin).not.toBe(-1); const config = JSON.parse(output.slice(configBegin)); - - // From Playwright cli defaults. expect(config.browser.launchOptions.headless).toBe(true); - - // From config file. expect(config.browser.contextOptions.viewport).toEqual({ width: 800, height: 600 }); expect(config.timeouts.action).toBe(10000); - - // Env var overrides file value. expect(config.timeouts.navigation).toBe(45000); - - // From CLI arg (--in-memory). expect(config.browser.isolated).toBe(true); }); diff --git a/tests/mcp/cli-persistent.spec.ts b/tests/mcp/cli-persistent.spec.ts new file mode 100644 index 0000000000000..69a49383b036c --- /dev/null +++ b/tests/mcp/cli-persistent.spec.ts @@ -0,0 +1,34 @@ +/** + * Copyright (c) Microsoft Corporation. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import fs from 'fs'; +import { test, expect } from './cli-fixtures'; + +test('should use userDataDir from config without --persistent flag', async ({ cli, server }, testInfo) => { + const customDir = testInfo.outputPath('my-profile'); + const config = { + browser: { + userDataDir: customDir, + }, + }; + await fs.promises.writeFile(testInfo.outputPath('config.json'), JSON.stringify(config, null, 2)); + await cli('open', `--config=config.json`, server.HELLO_WORLD); + expect(fs.existsSync(customDir)).toBe(true); + + const { output: listOutput } = await cli('list'); + expect(listOutput).not.toContain('user-data-dir: '); + expect(listOutput).toContain(`user-data-dir: ${customDir}`); +}); diff --git a/tests/mcp/http.spec.ts b/tests/mcp/http.spec.ts index 555bf67c64733..e3d997c1c8a52 100644 --- a/tests/mcp/http.spec.ts +++ b/tests/mcp/http.spec.ts @@ -37,7 +37,7 @@ const test = baseTest.extend<{ serverEndpoint: (options?: { args?: string[], noP cp = spawn('node', [ ...mcpServerPath, ...(options?.noPort ? [] : ['--port=0']), - '--user-data-dir=' + userDataDir, + ...(!options?.args?.includes('--isolated') ? ['--user-data-dir=' + userDataDir] : []), ...(mcpHeadless ? ['--headless'] : []), ...(options?.args || []), ], { diff --git a/tests/mcp/sse.spec.ts b/tests/mcp/sse.spec.ts index 8af6c1a1e9e40..2785a0c81e9bb 100644 --- a/tests/mcp/sse.spec.ts +++ b/tests/mcp/sse.spec.ts @@ -34,7 +34,7 @@ const test = baseTest.extend<{ serverEndpoint: (options?: { args?: string[], noP cp = spawn('node', [ ...mcpServerPath, ...(options?.noPort ? [] : ['--port=0']), - '--user-data-dir=' + userDataDir, + ...(!options?.args?.includes('--isolated') ? ['--user-data-dir=' + userDataDir] : []), ...(mcpHeadless ? ['--headless'] : []), ...(options?.args || []), ], { From b085f91099f8cf20aa3f7f86c41b77113c867177 Mon Sep 17 00:00:00 2001 From: Dmitry Gozman Date: Tue, 10 Mar 2026 03:41:52 +0000 Subject: [PATCH 5/5] chore: follow up to loopback proxy bypass (#39579) --- .../src/server/android/android.ts | 4 +- .../playwright-core/src/server/bidi/DEPS.list | 1 + .../src/server/bidi/bidiChromium.ts | 5 +- .../src/server/chromium/chromium.ts | 7 +-- .../src/server/chromium/crBrowser.ts | 14 +++-- tests/config/proxy.ts | 10 ++-- tests/library/proxy.spec.ts | 53 ++++++++++++++----- tests/playwright-test/ui-mode-trace.spec.ts | 2 +- 8 files changed, 63 insertions(+), 33 deletions(-) diff --git a/packages/playwright-core/src/server/android/android.ts b/packages/playwright-core/src/server/android/android.ts index db1616bd16c35..c45ea10599758 100644 --- a/packages/playwright-core/src/server/android/android.ts +++ b/packages/playwright-core/src/server/android/android.ts @@ -29,7 +29,7 @@ import { debug } from '../../utilsBundle'; import { wsReceiver, wsSender } from '../../utilsBundle'; import { validateBrowserContextOptions } from '../browserContext'; import { chromiumSwitches } from '../chromium/chromiumSwitches'; -import { CRBrowser } from '../chromium/crBrowser'; +import { shouldProxyLoopback, CRBrowser } from '../chromium/crBrowser'; import { removeFolders } from '../utils/fileUtils'; import { helper } from '../helper'; import { SdkObject } from '../instrumentation'; @@ -301,7 +301,7 @@ export class AndroidDevice extends SdkObject { const proxyBypassRules = []; if (proxy.bypass) proxyBypassRules.push(...proxy.bypass.split(',').map(t => t.trim()).map(t => t.startsWith('.') ? '*' + t : t)); - if (!process.env.PLAYWRIGHT_DISABLE_FORCED_CHROMIUM_PROXIED_LOOPBACK && !proxyBypassRules.includes('<-loopback>')) + if (shouldProxyLoopback(proxy.bypass)) proxyBypassRules.push('<-loopback>'); if (proxyBypassRules.length > 0) chromeArguments.push(`--proxy-bypass-list=${proxyBypassRules.join(';')}`); diff --git a/packages/playwright-core/src/server/bidi/DEPS.list b/packages/playwright-core/src/server/bidi/DEPS.list index ab509f181c876..76fcdbab98d96 100644 --- a/packages/playwright-core/src/server/bidi/DEPS.list +++ b/packages/playwright-core/src/server/bidi/DEPS.list @@ -12,3 +12,4 @@ [bidiChromium.ts] ../chromium/chromiumSwitches.ts ../chromium/chromium.ts +../chromium/crBrowser.ts diff --git a/packages/playwright-core/src/server/bidi/bidiChromium.ts b/packages/playwright-core/src/server/bidi/bidiChromium.ts index d44a2e2a38c52..1febca2ea2ec0 100644 --- a/packages/playwright-core/src/server/bidi/bidiChromium.ts +++ b/packages/playwright-core/src/server/bidi/bidiChromium.ts @@ -23,6 +23,7 @@ import { kBrowserCloseMessageId } from './bidiConnection'; import { chromiumSwitches } from '../chromium/chromiumSwitches'; import { RecentLogsCollector } from '../utils/debugLogger'; import { waitForReadyState } from '../chromium/chromium'; +import { shouldProxyLoopback } from '../chromium/crBrowser'; import type { BrowserOptions } from '../browser'; import type { SdkObject } from '../instrumentation'; @@ -142,11 +143,9 @@ export class BidiChromium extends BrowserType { chromeArguments.push(`--proxy-server=${proxy.server}`); const proxyBypassRules = []; // https://source.chromium.org/chromium/chromium/src/+/master:net/docs/proxy.md;l=548;drc=71698e610121078e0d1a811054dcf9fd89b49578 - if (options.socksProxyPort) - proxyBypassRules.push('<-loopback>'); if (proxy.bypass) proxyBypassRules.push(...proxy.bypass.split(',').map(t => t.trim()).map(t => t.startsWith('.') ? '*' + t : t)); - if (!process.env.PLAYWRIGHT_DISABLE_FORCED_CHROMIUM_PROXIED_LOOPBACK && !proxyBypassRules.includes('<-loopback>')) + if (options.socksProxyPort || shouldProxyLoopback(proxy.bypass)) proxyBypassRules.push('<-loopback>'); if (proxyBypassRules.length > 0) chromeArguments.push(`--proxy-bypass-list=${proxyBypassRules.join(';')}`); diff --git a/packages/playwright-core/src/server/chromium/chromium.ts b/packages/playwright-core/src/server/chromium/chromium.ts index f0b955c13c631..ada51cd440aa9 100644 --- a/packages/playwright-core/src/server/chromium/chromium.ts +++ b/packages/playwright-core/src/server/chromium/chromium.ts @@ -20,7 +20,7 @@ import os from 'os'; import path from 'path'; import { chromiumSwitches } from './chromiumSwitches'; -import { CRBrowser } from './crBrowser'; +import { shouldProxyLoopback, CRBrowser } from './crBrowser'; import { kBrowserCloseMessageId } from './crConnection'; import { debugMode, headersArrayToObject, headersObjectToArray, } from '../../utils'; import { wrapInASCIIBox } from '../utils/ascii'; @@ -345,12 +345,9 @@ export class Chromium extends BrowserType { chromeArguments.push(`--proxy-server=${proxy.server}`); const proxyBypassRules = []; // https://source.chromium.org/chromium/chromium/src/+/master:net/docs/proxy.md;l=548;drc=71698e610121078e0d1a811054dcf9fd89b49578 - if (options.socksProxyPort) - proxyBypassRules.push('<-loopback>'); if (proxy.bypass) proxyBypassRules.push(...proxy.bypass.split(',').map(t => t.trim()).map(t => t.startsWith('.') ? '*' + t : t)); - const bypassesLoopback = proxyBypassRules.some(rule => rule === '<-loopback>' || rule === 'localhost' || rule === '127.0.0.1' || rule === '::1'); - if (!process.env.PLAYWRIGHT_DISABLE_FORCED_CHROMIUM_PROXIED_LOOPBACK && !bypassesLoopback) + if (options.socksProxyPort || shouldProxyLoopback(proxy.bypass)) proxyBypassRules.push('<-loopback>'); if (proxyBypassRules.length > 0) chromeArguments.push(`--proxy-bypass-list=${proxyBypassRules.join(';')}`); diff --git a/packages/playwright-core/src/server/chromium/crBrowser.ts b/packages/playwright-core/src/server/chromium/crBrowser.ts index d4b3fa68928b0..e5a1df65cee14 100644 --- a/packages/playwright-core/src/server/chromium/crBrowser.ts +++ b/packages/playwright-core/src/server/chromium/crBrowser.ts @@ -112,10 +112,10 @@ export class CRBrowser extends Browser { const proxy = options.proxyOverride || options.proxy; let proxyBypassList = undefined; if (proxy) { - if (process.env.PLAYWRIGHT_DISABLE_FORCED_CHROMIUM_PROXIED_LOOPBACK) - proxyBypassList = proxy.bypass; - else + if (shouldProxyLoopback(proxy.bypass)) proxyBypassList = '<-loopback>' + (proxy.bypass ? `,${proxy.bypass}` : ''); + else + proxyBypassList = proxy.bypass; } const { browserContextId } = await this._session.send('Target.createBrowserContext', { @@ -617,3 +617,11 @@ export class CRBrowserContext extends BrowserContext { return rootSession.attachToTarget(targetId); } } + +export function shouldProxyLoopback(bypass: string | undefined) { + if (process.env.PLAYWRIGHT_DISABLE_FORCED_CHROMIUM_PROXIED_LOOPBACK) + return false; + const hosts = (bypass || '').split(',').map(s => s.trim()); + const shouldBypassSomeLoopback = ['localhost', '127.0.0.1', '::1', '[::]', '[::1]', '', '<-loopback>'].some(host => hosts.includes(host)); + return !shouldBypassSomeLoopback; +} diff --git a/tests/config/proxy.ts b/tests/config/proxy.ts index e1ac6ab5a3cbb..6fbad1ca7f4b5 100644 --- a/tests/config/proxy.ts +++ b/tests/config/proxy.ts @@ -62,7 +62,7 @@ export class TestProxy { await new Promise(x => this._server.close(x)); } - forwardTo(port: number, options?: { allowConnectRequests?: boolean, prefix?: string, preserveHostname?: boolean }) { + forwardTo(port: number, options?: { allowConnectRequests?: boolean, removePrefix?: string, preserveHostname?: boolean }) { this._prependHandler('request', (req: IncomingMessage) => { this.requestUrls.push(req.url); const url = new URL(req.url, `http://${req.headers.host}`); @@ -70,8 +70,8 @@ export class TestProxy { url.port = '' + port; else url.host = `127.0.0.1:${port}`; - if (options?.prefix) - url.pathname = url.pathname.replace(options.prefix, ''); + if (options?.removePrefix) + url.pathname = url.pathname.replace(options.removePrefix, ''); req.url = url.toString(); }); this._prependHandler('connect', (req: IncomingMessage) => { @@ -89,8 +89,8 @@ export class TestProxy { url.port = '' + port; else url.host = `127.0.0.1:${port}`; - if (options?.prefix) - url.pathname = url.pathname.replace(options.prefix, ''); + if (options?.removePrefix) + url.pathname = url.pathname.replace(options.removePrefix, ''); if (url.protocol === 'ws:') url.protocol = 'http:'; else if (url.protocol === 'wss:') diff --git a/tests/library/proxy.spec.ts b/tests/library/proxy.spec.ts index 97cfeaf5a07c0..05c91c7277a40 100644 --- a/tests/library/proxy.spec.ts +++ b/tests/library/proxy.spec.ts @@ -123,6 +123,28 @@ it.describe('should proxy local network requests', () => { } }); +for (const host of ['localhost', '127.0.0.1', '[::1]']) { + it(`should allow bypassing ${host} requests`, async ({ browserName, browserType, server, proxyServer }) => { + it.fixme(browserName === 'firefox' && host === '[::1]', 'firefox still proxies'); + + server.setRoute(`/proxied/target.html`, async (req, res) => { + res.end('Served by the server'); + }); + server.setRoute(`/target.html`, async (req, res) => { + res.end('Served by the proxy'); + }); + proxyServer.forwardTo(server.PORT, { removePrefix: '/proxied' }); + + const browser = await browserType.launch({ + proxy: { server: `localhost:${proxyServer.PORT}`, bypass: host } + }); + const page = await browser.newPage(); + await page.goto(`http://${host}:${server.PORT}/proxied/target.html`); + expect(await page.title()).toBe('Served by the server'); + await browser.close(); + }); +} + it('should authenticate', async ({ browserType, server }) => { server.setRoute('/target.html', async (req, res) => { const auth = req.headers['proxy-authorization']; @@ -228,21 +250,24 @@ it('should exclude patterns', async ({ browserType, server, channel }) => { await browser.close(); }); -it('should bypass proxy for localhost when localhost is in bypass list', async ({ browserType, server, proxyServer }) => { - proxyServer.forwardTo(server.PORT); - server.setRoute('/target.html', async (req, res) => { - res.end('Served by the proxy'); - }); - const browser = await browserType.launch({ - proxy: { server: `localhost:${proxyServer.PORT}`, bypass: 'localhost' } +for (const host of ['localhost', '127.0.0.1']) { + it(`should bypass proxy for ${host} when ${host} is in bypass list`, async ({ browserType, server, proxyServer }) => { + proxyServer.forwardTo(server.PORT, { removePrefix: '/proxied' }); + server.setRoute(`/proxied/target.html`, async (req, res) => { + res.end('Served by the server'); + }); + server.setRoute('/target.html', async (req, res) => { + res.end('Served by the proxy'); + }); + const browser = await browserType.launch({ + proxy: { server: `localhost:${proxyServer.PORT}`, bypass: host } + }); + const page = await browser.newPage(); + await page.goto(`http://${host}:${server.PORT}/proxied/target.html`); + expect(await page.title()).toBe('Served by the server'); + await browser.close(); }); - const page = await browser.newPage(); - // Navigate to localhost - should bypass the proxy and hit the server directly. - await page.goto(`http://localhost:${server.PORT}/target.html`); - expect(proxyServer.requestUrls).not.toContain(`http://localhost:${server.PORT}/target.html`); - expect(await page.title()).toBe('Served by the proxy'); - await browser.close(); -}); +} it('should use socks proxy', async ({ browserType, socksPort }) => { const browser = await browserType.launch({ diff --git a/tests/playwright-test/ui-mode-trace.spec.ts b/tests/playwright-test/ui-mode-trace.spec.ts index 9de325547ae6d..bb89bb41ddd35 100644 --- a/tests/playwright-test/ui-mode-trace.spec.ts +++ b/tests/playwright-test/ui-mode-trace.spec.ts @@ -443,7 +443,7 @@ test('should work behind reverse proxy', { annotation: { type: 'issue', descript }); const uiModeUrl = new URL(page.url()); - reverseProxy.forwardTo(+uiModeUrl.port, { prefix: '/subdir', preserveHostname: true }); + reverseProxy.forwardTo(+uiModeUrl.port, { removePrefix: '/subdir', preserveHostname: true }); await page.goto(`${reverseProxy.URL}/subdir${uiModeUrl.pathname}?${uiModeUrl.searchParams}`); await page.getByText('trace test').dblclick();