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: