From 8708b7e3407ee43cda83a99ae1a9e7430b9d32bf Mon Sep 17 00:00:00 2001 From: Pavel Feldman Date: Tue, 10 Mar 2026 16:32:30 -0700 Subject: [PATCH 1/5] fix(mcp): persistent context is always shared (#39601) --- CLAUDE.md | 1 + packages/playwright-core/src/mcp/config.d.ts | 5 ---- packages/playwright-core/src/mcp/config.ts | 2 -- packages/playwright-core/src/mcp/configIni.ts | 1 - packages/playwright-core/src/mcp/program.ts | 9 +++++-- tests/mcp/http.spec.ts | 27 +------------------ tests/mcp/sse.spec.ts | 27 +------------------ 7 files changed, 10 insertions(+), 62 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index d168ae9d1b9fc..858166243e0f6 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -122,6 +122,7 @@ EOF )" ``` +Never add Co-Authored-By agents in commit message. Branch naming for issue fixes: `fix-` ## Development Guides diff --git a/packages/playwright-core/src/mcp/config.d.ts b/packages/playwright-core/src/mcp/config.d.ts index 35c5ca61fb225..99ad58fbf9523 100644 --- a/packages/playwright-core/src/mcp/config.d.ts +++ b/packages/playwright-core/src/mcp/config.d.ts @@ -137,11 +137,6 @@ export type Config = { */ saveSession?: boolean; - /** - * Reuse the same browser context between all connected HTTP clients. - */ - sharedBrowserContext?: boolean; - /** * Secrets are used to prevent LLM from getting sensitive data while * automating scenarios such as authentication. diff --git a/packages/playwright-core/src/mcp/config.ts b/packages/playwright-core/src/mcp/config.ts index 577da213a873e..1088e834a6c14 100644 --- a/packages/playwright-core/src/mcp/config.ts +++ b/packages/playwright-core/src/mcp/config.ts @@ -64,7 +64,6 @@ export type CLIOptions = { proxyServer?: string; saveSession?: boolean; secrets?: Record; - sharedBrowserContext?: boolean; snapshotMode?: 'incremental' | 'full' | 'none'; storageState?: string; testIdAttribute?: string; @@ -253,7 +252,6 @@ export function configFromCLIOptions(cliOptions: CLIOptions): Config & { configF codegen: cliOptions.codegen, saveSession: cliOptions.saveSession, secrets: cliOptions.secrets, - sharedBrowserContext: cliOptions.sharedBrowserContext, snapshot: cliOptions.snapshotMode ? { mode: cliOptions.snapshotMode } : undefined, outputMode: cliOptions.outputMode, outputDir: cliOptions.outputDir, diff --git a/packages/playwright-core/src/mcp/configIni.ts b/packages/playwright-core/src/mcp/configIni.ts index 966e4ab617c9b..29c4c743e7955 100644 --- a/packages/playwright-core/src/mcp/configIni.ts +++ b/packages/playwright-core/src/mcp/configIni.ts @@ -160,7 +160,6 @@ const longhandTypes: Record = { 'saveSession': 'boolean', 'saveTrace': 'boolean', 'saveVideo': 'size', - 'sharedBrowserContext': 'boolean', 'outputDir': 'string', 'outputMode': 'string', 'imageResponses': 'string', diff --git a/packages/playwright-core/src/mcp/program.ts b/packages/playwright-core/src/mcp/program.ts index c60b789c088c5..f82bf80b47755 100644 --- a/packages/playwright-core/src/mcp/program.ts +++ b/packages/playwright-core/src/mcp/program.ts @@ -26,6 +26,7 @@ import { testDebug } from './log'; import type { Command } from '../utilsBundle'; import type { ClientInfo } from './sdk/server'; +import type * as playwright from '../..'; export function decorateMCPCommand(command: Command, version: string) { command @@ -62,7 +63,6 @@ export function decorateMCPCommand(command: Command, version: string) { .option('--sandbox', 'enable the sandbox for all process types that are normally not sandboxed.') .option('--save-session', 'Whether to save the Playwright MCP session into the output directory.') .option('--secrets ', 'path to a file containing secrets in the dotenv format', dotenvFileLoader) - .option('--shared-browser-context', 'reuse the same browser context between all connected HTTP clients.') .option('--snapshot-mode ', 'when taking snapshots for responses, specifies the mode to use. Can be "incremental", "full", or "none". Default is incremental.') .option('--storage-state ', 'path to the storage state file for isolated sessions.') .option('--test-id-attribute ', 'specify the attribute to use for test ids, defaults to "data-testid"') @@ -107,14 +107,18 @@ export function decorateMCPCommand(command: Command, version: string) { return; } - const sharedBrowser = config.sharedBrowserContext ? await createBrowser(config, { cwd: process.cwd() }) : undefined; + const useSharedBrowser = !!config.browser.userDataDir; + let sharedBrowser: playwright.Browser | undefined; let clientCount = 0; + const factory: mcpServer.ServerBackendFactory = { name: 'Playwright', nameInConfig: 'playwright', version, toolSchemas: tools.map(tool => tool.schema), create: async (clientInfo: ClientInfo) => { + if (useSharedBrowser && clientCount === 0) + sharedBrowser = await createBrowser(config, clientInfo); clientCount++; const browser = sharedBrowser || await createBrowser(config, clientInfo); const browserContext = config.browser.isolated ? await browser.newContext(config.browser.contextOptions) : browser.contexts()[0]; @@ -126,6 +130,7 @@ export function decorateMCPCommand(command: Command, version: string) { return; testDebug('close browser'); + sharedBrowser = undefined; const browserContext = (backend as BrowserServerBackend).browserContext; await browserContext.close().catch(() => { }); await browserContext.browser()!.close().catch(() => { }); diff --git a/tests/mcp/http.spec.ts b/tests/mcp/http.spec.ts index e3d997c1c8a52..1e0b9765b9cf5 100644 --- a/tests/mcp/http.spec.ts +++ b/tests/mcp/http.spec.ts @@ -236,33 +236,8 @@ test('http transport browser lifecycle (persistent)', async ({ serverEndpoint, s }); }); -test('http transport browser lifecycle (persistent, multiclient)', async ({ serverEndpoint, server }) => { - const { url } = await serverEndpoint(); - - const transport1 = new StreamableHTTPClientTransport(new URL('/mcp', url)); - const client1 = new Client({ name: 'test', version: '1.0.0' }); - await client1.connect(transport1); - await client1.callTool({ - name: 'browser_navigate', - arguments: { url: server.HELLO_WORLD }, - }); - - const transport2 = new StreamableHTTPClientTransport(new URL('/mcp', url)); - const client2 = new Client({ name: 'test', version: '1.0.0' }); - await client2.connect(transport2); - const response = await client2.callTool({ - name: 'browser_navigate', - arguments: { url: server.HELLO_WORLD }, - }); - expect(response.isError).toBe(true); - expect(response.content?.[0].text).toContain('use --isolated to run multiple instances of the same browser'); - - await client1.close(); - await client2.close(); -}); - test('http transport shared context', async ({ serverEndpoint, server }) => { - const { url, stderr } = await serverEndpoint({ args: ['--shared-browser-context'] }); + const { url, stderr } = await serverEndpoint(); // Create first client and navigate const transport1 = new StreamableHTTPClientTransport(new URL('/mcp', url)); diff --git a/tests/mcp/sse.spec.ts b/tests/mcp/sse.spec.ts index 2785a0c81e9bb..322d5bef711d6 100644 --- a/tests/mcp/sse.spec.ts +++ b/tests/mcp/sse.spec.ts @@ -185,33 +185,8 @@ test('sse transport browser lifecycle (persistent)', async ({ serverEndpoint, se }); }); -test('sse transport browser lifecycle (persistent, multiclient)', async ({ serverEndpoint, server }) => { - const { url } = await serverEndpoint(); - - const transport1 = new SSEClientTransport(new URL('/sse', url)); - const client1 = new Client({ name: 'test', version: '1.0.0' }); - await client1.connect(transport1); - await client1.callTool({ - name: 'browser_navigate', - arguments: { url: server.HELLO_WORLD }, - }); - - const transport2 = new SSEClientTransport(new URL('/sse', url)); - const client2 = new Client({ name: 'test', version: '1.0.0' }); - await client2.connect(transport2); - const response = await client2.callTool({ - name: 'browser_navigate', - arguments: { url: server.HELLO_WORLD }, - }); - expect(response.isError).toBe(true); - expect(response.content?.[0].text).toContain('use --isolated to run multiple instances of the same browser'); - - await client1.close(); - await client2.close(); -}); - test('sse transport shared context', async ({ serverEndpoint, server }) => { - const { url, stderr } = await serverEndpoint({ args: ['--shared-browser-context'] }); + const { url, stderr } = await serverEndpoint(); // Create first client and navigate const transport1 = new SSEClientTransport(new URL('/sse', url)); From 78d40c07f4912c8150a064b6b2ed3c46ad14eb01 Mon Sep 17 00:00:00 2001 From: Pavel Feldman Date: Tue, 10 Mar 2026 17:19:53 -0700 Subject: [PATCH 2/5] chore: remove server dependencies from serverRegistry (#39604) --- packages/devtools/src/grid.tsx | 8 +-- packages/devtools/src/sessionModel.ts | 10 ++- .../src/devtools/devtoolsApp.ts | 42 ++++++------- .../src/devtools/devtoolsController.ts | 3 +- .../playwright-core/src/server/browser.ts | 24 ++++++-- .../playwright-core/src/serverRegistry.ts | 61 ++++++++----------- tests/mcp/cli-fixtures.ts | 1 + 7 files changed, 74 insertions(+), 75 deletions(-) diff --git a/packages/devtools/src/grid.tsx b/packages/devtools/src/grid.tsx index de27e32e25a20..fc5d4a7346e0b 100644 --- a/packages/devtools/src/grid.tsx +++ b/packages/devtools/src/grid.tsx @@ -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.browserDescriptor.workspaceDir || 'Global'; + const key = session.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.browserDescriptor.title.localeCompare(b.browserDescriptor.title)); + list.sort((a, b) => a.title.localeCompare(b.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(session => )} + {entries.map(session => )}
)} @@ -103,7 +103,7 @@ export const Grid: React.FC<{ model: SessionModel }> = ({ model }) => { }; const SessionChip: React.FC<{ descriptor: BrowserDescriptor; wsUrl: string | undefined; visible: boolean; model: SessionModel }> = ({ descriptor, wsUrl, visible, model }) => { - const href = '#session=' + encodeURIComponent(descriptor.guid); + const href = '#session=' + encodeURIComponent(descriptor.browser.guid); const channel = React.useMemo(() => { if (!wsUrl || !visible) diff --git a/packages/devtools/src/sessionModel.ts b/packages/devtools/src/sessionModel.ts index 869e378938352..74526f9e58cc8 100644 --- a/packages/devtools/src/sessionModel.ts +++ b/packages/devtools/src/sessionModel.ts @@ -17,12 +17,10 @@ import type { ClientInfo } from '../../playwright-core/src/cli/client/registry'; import type { BrowserDescriptor } from '../../playwright-core/src/serverRegistry'; -export type SessionStatus = { - browserDescriptor: BrowserDescriptor; +export type SessionStatus = BrowserDescriptor & { wsUrl?: string; }; - type Listener = () => void; export class SessionModel { @@ -67,7 +65,7 @@ export class SessionModel { } sessionByGuid(guid: string): SessionStatus | undefined { - return this.sessions.find(s => s.browserDescriptor.guid === guid); + return this.sessions.find(s => s.browser.guid === guid); } private async _fetchSessions() { @@ -103,7 +101,7 @@ export class SessionModel { await fetch('/api/sessions/close', { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ sessionGuid: descriptor.guid }), + body: JSON.stringify({ guid: descriptor.browser.guid }), }); await this._fetchSessions(); } @@ -112,7 +110,7 @@ export class SessionModel { await fetch('/api/sessions/delete-data', { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ sessionGuid: descriptor.guid }), + body: JSON.stringify({ guid: descriptor.browser.guid }), }); await this._fetchSessions(); } diff --git a/packages/playwright-core/src/devtools/devtoolsApp.ts b/packages/playwright-core/src/devtools/devtoolsApp.ts index 4025f299afc02..174bddf44314e 100644 --- a/packages/playwright-core/src/devtools/devtoolsApp.ts +++ b/packages/playwright-core/src/devtools/devtoolsApp.ts @@ -48,11 +48,11 @@ function readBody(request: http.IncomingMessage): Promise { }); } -async function parseRequest(request: http.IncomingMessage): Promise<{ sessionGuid: string }> { +async function parseRequest(request: http.IncomingMessage): Promise<{ guid: string }> { const body = await readBody(request); - if (!body.sessionGuid) + if (!body.guid) throw new Error('Dashboard app is too old, please close it and open again'); - return { sessionGuid: body.sessionGuid }; + return { guid: body.guid }; } function sendJSON(response: http.ServerResponse, data: any, statusCode = 200) { @@ -62,17 +62,17 @@ function sendJSON(response: http.ServerResponse, data: any, statusCode = 200) { } async function loadBrowserDescriptorSessions(wsPath: string): Promise { - const servers = await serverRegistry.list(); + const entriesByWorkspace = await serverRegistry.list(); const sessions: SessionStatus[] = []; - for (const [, browsers] of servers) { - for (const browser of browsers) { + for (const [, entries] of entriesByWorkspace) { + for (const entry of entries) { let wsUrl: string | undefined; - if (browser.canConnect) { + if (entry.canConnect) { const url = new URL(wsPath, 'http://localhost'); - url.searchParams.set('sessionGuid', browser.guid); + url.searchParams.set('guid', entry.browser.guid); wsUrl = url.pathname + url.search; } - sessions.push({ browserDescriptor: browser, wsUrl }); + sessions.push({ ...entry, wsUrl }); } } return sessions; @@ -91,10 +91,10 @@ async function handleApiRequest(httpServer: HttpServer, request: http.IncomingMe } if (apiPath === '/api/sessions/close' && request.method === 'POST') { - const { sessionGuid } = await parseRequest(request); + const { guid } = await parseRequest(request); let browser: api.Browser; try { - const browserDescriptor = serverRegistry.readDescriptor(sessionGuid); + const browserDescriptor = serverRegistry.readDescriptor(guid); browser = await connectToBrowserAcrossVersions(browserDescriptor); } catch (e) { sendJSON(response, { error: 'Failed to connect to browser socket: ' + e.message }, 500); @@ -112,9 +112,9 @@ async function handleApiRequest(httpServer: HttpServer, request: http.IncomingMe } if (apiPath === '/api/sessions/delete-data' && request.method === 'POST') { - const { sessionGuid } = await parseRequest(request); + const { guid } = await parseRequest(request); try { - await serverRegistry.deleteUserData(sessionGuid); + await serverRegistry.deleteUserData(guid); } catch (e) { sendJSON(response, { error: 'Failed to delete session data: ' + e.message }, 500); return; @@ -141,16 +141,16 @@ async function openDevToolsApp(): Promise { }); httpServer.createWebSocket(url => { - const sessionGuid = url.searchParams.get('sessionGuid'); - if (!sessionGuid) + const guid = url.searchParams.get('guid'); + if (!guid) throw new Error('Unsupported WebSocket URL: ' + url.toString()); - const browserDescriptor = serverRegistry.readDescriptor(sessionGuid); + const browserDescriptor = serverRegistry.readDescriptor(guid); const cdpPageId = url.searchParams.get('cdpPageId'); if (cdpPageId) { - const connection = browserGuidToDevToolsConnection.get(sessionGuid); + const connection = browserGuidToDevToolsConnection.get(guid); if (!connection) - throw new Error('CDP connection not found for session: ' + sessionGuid); + throw new Error('CDP connection not found for session: ' + guid); const page = connection.pageForId(cdpPageId); if (!page) throw new Error('Page not found for page ID: ' + cdpPageId); @@ -159,9 +159,9 @@ async function openDevToolsApp(): Promise { const cdpUrl = new URL(httpServer.urlPrefix('human-readable')); cdpUrl.pathname = httpServer.wsGuid()!; - cdpUrl.searchParams.set('sessionGuid', sessionGuid); - const connection = new DevToolsConnection(browserDescriptor, cdpUrl, () => browserGuidToDevToolsConnection.delete(sessionGuid)); - browserGuidToDevToolsConnection.set(sessionGuid, connection); + cdpUrl.searchParams.set('guid', guid); + const connection = new DevToolsConnection(browserDescriptor, cdpUrl, () => browserGuidToDevToolsConnection.delete(guid)); + browserGuidToDevToolsConnection.set(guid, connection); return connection; }); diff --git a/packages/playwright-core/src/devtools/devtoolsController.ts b/packages/playwright-core/src/devtools/devtoolsController.ts index a1c5a45dc94d7..0d876b2f15a08 100644 --- a/packages/playwright-core/src/devtools/devtoolsController.ts +++ b/packages/playwright-core/src/devtools/devtoolsController.ts @@ -101,6 +101,7 @@ export class DevToolsConnection implements Transport, DevToolsChannel { this._contextListeners.forEach(d => d.dispose()); this._contextListeners = []; this._onclose(); + this._browser?.close().catch(() => {}); } async dispatch(method: string, params: any): Promise { @@ -259,7 +260,7 @@ export class DevToolsConnection implements Transport, DevToolsChannel { } private async _devtoolsUrl(page: api.Page) { - const cdpPort = this._browserDescriptor.browser.launchOptions.cdpPort; + const cdpPort = (this._browserDescriptor.browser.launchOptions as any).cdpPort; if (cdpPort) return new URL(`http://localhost:${cdpPort}/devtools/`); diff --git a/packages/playwright-core/src/server/browser.ts b/packages/playwright-core/src/server/browser.ts index a3247248246b6..c065e68f4dc12 100644 --- a/packages/playwright-core/src/server/browser.ts +++ b/packages/playwright-core/src/server/browser.ts @@ -26,7 +26,7 @@ import { Page } from './page'; import { ClientCertificatesProxy } from './socksClientCertificatesInterceptor'; import { PlaywrightPipeServer } from '../remote/playwrightPipeServer'; import { PlaywrightWebSocketServer } from '../remote/playwrightWebSocketServer'; -import { serverRegistry } from '../serverRegistry'; +import { BrowserInfo, serverRegistry } from '../serverRegistry'; import type * as types from './types'; import type { ProxySettings } from './types'; @@ -35,6 +35,7 @@ import type * as channels from '@protocol/channels'; import type { ChildProcess } from 'child_process'; import type { Language } from '../utils'; import type { Progress } from './progress'; +import type * as playwright from '../..'; export interface BrowserProcess { onclose?: ((exitCode: number | null, signal: string | null) => void); @@ -213,7 +214,6 @@ export class BrowserServer { private _wsServer?: PlaywrightWebSocketServer; private _pipeSocketPath?: string; private _isStarted = false; - private _sessionGuid?: string; constructor(browser: Browser) { this._browser = browser; @@ -236,7 +236,13 @@ export class BrowserServer { result.wsEndpoint = await this._wsServer.listen(0, 'localhost', path); } - this._sessionGuid = await serverRegistry.create(this._browser, { + const browserInfo: BrowserInfo = { + guid: this._browser.guid, + browserName: this._browser.options.browserType, + launchOptions: asClientLaunchOptions(this._browser.options.originalLaunchOptions), + userDataDir: this._browser.options.userDataDir, + }; + await serverRegistry.create(browserInfo, { title, wsEndpoint: result.wsEndpoint, pipeName: result.pipeName, @@ -246,9 +252,8 @@ export class BrowserServer { } async stop() { - if (this._sessionGuid) - await serverRegistry.delete(this._browser, this._sessionGuid); - this._sessionGuid = undefined; + if (!this._browser.options.userDataDir) + await serverRegistry.delete(this._browser.guid); if (this._pipeSocketPath && process.platform !== 'win32') await fs.promises.unlink(this._pipeSocketPath).catch(() => {}); await this._pipeServer?.close(); @@ -267,3 +272,10 @@ export class BrowserServer { return path.join(socketsDir, socketName); } } + +function asClientLaunchOptions(serverOptions: types.LaunchOptions): playwright.LaunchOptions { + return { + ...serverOptions, + env: serverOptions.env ? Object.fromEntries(serverOptions.env.map(({ name, value }) => [name, value])) : undefined, + }; +} diff --git a/packages/playwright-core/src/serverRegistry.ts b/packages/playwright-core/src/serverRegistry.ts index e9d28433e6474..4aa14109697b4 100644 --- a/packages/playwright-core/src/serverRegistry.ts +++ b/packages/playwright-core/src/serverRegistry.ts @@ -18,30 +18,30 @@ import fs from 'fs'; import net from 'net'; import path from 'path'; import os from 'os'; -import crypto from 'crypto'; -import type { Browser } from './server/browser'; -import type { LaunchOptions } from './server/types'; -import type { BrowserName } from './server/registry/index'; +// Only client depenencies with backward compatibility guarantees should be imported here. +import type { LaunchOptions } from '../types/types'; const packageVersion = require('../package.json').version; export type BrowserInfo = { + guid: string; + browserName: 'chromium' | 'firefox' | 'webkit'; + userDataDir?: string; + launchOptions: LaunchOptions; +}; + +export type EndpointInfo = { title: string; wsEndpoint?: string; pipeName?: string; workspaceDir?: string; }; -export type BrowserDescriptor = BrowserInfo & { - guid: string; +export type BrowserDescriptor = EndpointInfo & { playwrightVersion: string; playwrightLib: string; - browser: { - browserName: BrowserName; - launchOptions: LaunchOptions; - userDataDir?: string; - }; + browser: BrowserInfo; }; export type BrowserStatus = BrowserDescriptor & { canConnect: boolean }; @@ -85,37 +85,28 @@ class ServerRegistry { return resolvedResult; } - async create(browser: Browser, info: BrowserInfo): Promise { - const guid = createGuid(); - const file = path.join(this._browsersDir(), guid); + async create(browser: BrowserInfo, endpoint: EndpointInfo) { + const file = path.join(this._browsersDir(), browser.guid); await fs.promises.mkdir(this._browsersDir(), { recursive: true }); const descriptor: BrowserDescriptor = { - guid, playwrightVersion: packageVersion, playwrightLib: require.resolve('..'), - title: info.title, - browser: { - browserName: browser.options.browserType, - launchOptions: browser.options.originalLaunchOptions, - userDataDir: browser.options.userDataDir, - }, - wsEndpoint: info.wsEndpoint, - pipeName: info.pipeName, - workspaceDir: info.workspaceDir, + title: endpoint.title, + browser, + wsEndpoint: endpoint.wsEndpoint, + pipeName: endpoint.pipeName, + workspaceDir: endpoint.workspaceDir, }; await fs.promises.writeFile(file, JSON.stringify(descriptor), 'utf-8'); - return guid; } - async delete(browser: Browser, sessionGuid: string): Promise { - if (browser.options.userDataDir) - return; - const file = path.join(this._browsersDir(), sessionGuid); + async delete(guid: string): Promise { + const file = path.join(this._browsersDir(), guid); await fs.promises.unlink(file).catch(() => {}); } - async deleteUserData(sessionGuid: string): Promise { - const filePath = path.join(this._browsersDir(), sessionGuid); + async deleteUserData(guid: string): Promise { + const filePath = path.join(this._browsersDir(), guid); const content = await fs.promises.readFile(filePath, 'utf-8'); const descriptor: BrowserDescriptor = JSON.parse(content); if (descriptor.browser.userDataDir) @@ -123,8 +114,8 @@ class ServerRegistry { await fs.promises.unlink(filePath); } - readDescriptor(sessionGuid: string): BrowserDescriptor { - const filePath = path.join(this._browsersDir(), sessionGuid); + readDescriptor(guid: string): BrowserDescriptor { + const filePath = path.join(this._browsersDir(), guid); const content = fs.readFileSync(filePath, 'utf-8'); const descriptor: BrowserDescriptor = JSON.parse(content); return descriptor; @@ -146,10 +137,6 @@ class ServerRegistry { } } -function createGuid(): string { - return crypto.randomBytes(16).toString('hex'); -} - async function canConnect(descriptor: BrowserDescriptor): Promise { if (descriptor.pipeName) { return await new Promise(resolve => { diff --git a/tests/mcp/cli-fixtures.ts b/tests/mcp/cli-fixtures.ts index a6a05ef7f33d2..300fe379590b0 100644 --- a/tests/mcp/cli-fixtures.ts +++ b/tests/mcp/cli-fixtures.ts @@ -64,6 +64,7 @@ export const test = baseTest.extend<{ function cliEnv() { return { + PLAYWRIGHT_SERVER_REGISTRY: test.info().outputPath('registry'), PLAYWRIGHT_DAEMON_SESSION_DIR: test.info().outputPath('daemon'), PLAYWRIGHT_DAEMON_SOCKETS_DIR: path.join(test.info().project.outputDir, 'ds'), }; From 2c6d59b71d19671bc468fa26c33990e3c51139c8 Mon Sep 17 00:00:00 2001 From: Yury Semikhatsky Date: Tue, 10 Mar 2026 17:27:56 -0700 Subject: [PATCH 3/5] feat(inspector): move pickLocator and cancelPickLocator to Page (#39605) --- docs/src/api/class-inspector.md | 20 --------- docs/src/api/class-page.md | 21 +++++++++ packages/playwright-client/types/types.d.ts | 43 +++++++++---------- .../playwright-core/src/client/inspector.ts | 10 ----- packages/playwright-core/src/client/page.ts | 9 ++++ .../src/devtools/devtoolsController.ts | 4 +- packages/playwright-core/types/types.d.ts | 43 +++++++++---------- tests/library/inspector/recorder-api.spec.ts | 10 ++--- 8 files changed, 79 insertions(+), 81 deletions(-) diff --git a/docs/src/api/class-inspector.md b/docs/src/api/class-inspector.md index 67789d029bd33..14d719134d739 100644 --- a/docs/src/api/class-inspector.md +++ b/docs/src/api/class-inspector.md @@ -4,26 +4,6 @@ Interface to the Playwright inspector. -## async method: Inspector.cancelPickLocator -* since: v1.59 - -Cancels an ongoing [`method: Inspector.pickLocator`] call by deactivating pick locator mode. -If no pick locator mode is active, this method is a no-op. - -## async method: Inspector.pickLocator -* since: v1.59 -- returns: <[Locator]> - -Enters pick locator mode where hovering over page elements highlights them and shows the corresponding locator. -Once the user clicks an element, the mode is deactivated and the [Locator] for the picked element is returned. - -**Usage** - -```js -const locator = await page.inspector().pickLocator(); -console.log(locator); -``` - ## event: Inspector.screencastFrame * since: v1.59 - argument: <[Object]> diff --git a/docs/src/api/class-page.md b/docs/src/api/class-page.md index 1b3093a52ebc8..10544cb6ca2a5 100644 --- a/docs/src/api/class-page.md +++ b/docs/src/api/class-page.md @@ -763,6 +763,13 @@ System prompt for the agent's loop. Brings page to front (activates tab). +## async method: Page.cancelPickLocator +* since: v1.59 +* langs: js + +Cancels an ongoing [`method: Page.pickLocator`] call by deactivating pick locator mode. +If no pick locator mode is active, this method is a no-op. + ## async method: Page.check * since: v1.8 * discouraged: Use locator-based [`method: Locator.check`] instead. Read more about [locators](../locators.md). @@ -3077,6 +3084,20 @@ Whether or not to generate tagged (accessible) PDF. Defaults to `false`. Whether or not to embed the document outline into the PDF. Defaults to `false`. +## async method: Page.pickLocator +* since: v1.59 +* langs: js +- returns: <[Locator]> + +Enters pick locator mode where hovering over page elements highlights them and shows the corresponding locator. +Once the user clicks an element, the mode is deactivated and the [Locator] for the picked element is returned. + +**Usage** + +```js +const locator = await page.pickLocator(); +console.log(locator); +``` ## async method: Page.press * since: v1.8 diff --git a/packages/playwright-client/types/types.d.ts b/packages/playwright-client/types/types.d.ts index c7b0375a83056..8af199cbed8e8 100644 --- a/packages/playwright-client/types/types.d.ts +++ b/packages/playwright-client/types/types.d.ts @@ -2187,6 +2187,12 @@ export interface Page { */ bringToFront(): Promise; + /** + * Cancels an ongoing [page.pickLocator()](https://playwright.dev/docs/api/class-page#page-pick-locator) call by + * deactivating pick locator mode. If no pick locator mode is active, this method is a no-op. + */ + cancelPickLocator(): Promise; + /** * **NOTE** Use locator-based [locator.check([options])](https://playwright.dev/docs/api/class-locator#locator-check) instead. * Read more about [locators](https://playwright.dev/docs/locators). @@ -3922,6 +3928,21 @@ export interface Page { width?: string|number; }): Promise; + /** + * Enters pick locator mode where hovering over page elements highlights them and shows the corresponding locator. + * Once the user clicks an element, the mode is deactivated and the + * [Locator](https://playwright.dev/docs/api/class-locator) for the picked element is returned. + * + * **Usage** + * + * ```js + * const locator = await page.pickLocator(); + * console.log(locator); + * ``` + * + */ + pickLocator(): Promise; + /** * **NOTE** Use locator-based [locator.press(key[, options])](https://playwright.dev/docs/api/class-locator#locator-press) * instead. Read more about [locators](https://playwright.dev/docs/locators). @@ -20686,28 +20707,6 @@ export interface Inspector { data: Buffer; }) => any): this; - /** - * Cancels an ongoing - * [inspector.pickLocator()](https://playwright.dev/docs/api/class-inspector#inspector-pick-locator) call by - * deactivating pick locator mode. If no pick locator mode is active, this method is a no-op. - */ - cancelPickLocator(): Promise; - - /** - * Enters pick locator mode where hovering over page elements highlights them and shows the corresponding locator. - * Once the user clicks an element, the mode is deactivated and the - * [Locator](https://playwright.dev/docs/api/class-locator) for the picked element is returned. - * - * **Usage** - * - * ```js - * const locator = await page.inspector().pickLocator(); - * console.log(locator); - * ``` - * - */ - pickLocator(): Promise; - /** * Starts capturing screencast frames. Frames are emitted as * [inspector.on('screencastframe')](https://playwright.dev/docs/api/class-inspector#inspector-event-screencast-frame) diff --git a/packages/playwright-core/src/client/inspector.ts b/packages/playwright-core/src/client/inspector.ts index 6c42f1e3363c3..81abf45bb5bd5 100644 --- a/packages/playwright-core/src/client/inspector.ts +++ b/packages/playwright-core/src/client/inspector.ts @@ -17,7 +17,6 @@ import { EventEmitter } from './eventEmitter'; import type * as api from '../../types/types'; -import type { Locator } from './locator'; import type { Page } from './page'; export class Inspector extends EventEmitter implements api.Inspector { @@ -29,15 +28,6 @@ export class Inspector extends EventEmitter implements api.Inspector { this._page._channel.on('screencastFrame', ({ data }) => this.emit('screencastframe', { data })); } - async pickLocator(): Promise { - const { selector } = await this._page._channel.pickLocator({}); - return this._page.locator(selector); - } - - async cancelPickLocator(): Promise { - await this._page._channel.cancelPickLocator({}); - } - async startScreencast(options: { maxSize?: { width: number, height: number } } = {}): Promise { await this._page._channel.startScreencast(options); } diff --git a/packages/playwright-core/src/client/page.ts b/packages/playwright-core/src/client/page.ts index ffc58e30b74d2..996b6e303b047 100644 --- a/packages/playwright-core/src/client/page.ts +++ b/packages/playwright-core/src/client/page.ts @@ -291,6 +291,15 @@ export class Page extends ChannelOwner implements api.Page return this._inspector; } + async pickLocator(): Promise { + const { selector } = await this._channel.pickLocator({}); + return this.locator(selector); + } + + async cancelPickLocator(): Promise { + await this._channel.cancelPickLocator({}); + } + async $(selector: string, options?: { strict?: boolean }): Promise | null> { return await this._mainFrame.$(selector, options); } diff --git a/packages/playwright-core/src/devtools/devtoolsController.ts b/packages/playwright-core/src/devtools/devtoolsController.ts index 0d876b2f15a08..fcb4206a745f3 100644 --- a/packages/playwright-core/src/devtools/devtoolsController.ts +++ b/packages/playwright-core/src/devtools/devtoolsController.ts @@ -219,12 +219,12 @@ export class DevToolsConnection implements Transport, DevToolsChannel { async pickLocator() { if (!this.selectedPage) return; - const locator = await this.selectedPage.inspector().pickLocator(); + const locator = await this.selectedPage.pickLocator(); this._emit('elementPicked', { selector: locator.toString() }); } async cancelPickLocator() { - await this.selectedPage?.inspector().cancelPickLocator(); + await this.selectedPage?.cancelPickLocator(); } private _sendCachedState() { diff --git a/packages/playwright-core/types/types.d.ts b/packages/playwright-core/types/types.d.ts index c7b0375a83056..8af199cbed8e8 100644 --- a/packages/playwright-core/types/types.d.ts +++ b/packages/playwright-core/types/types.d.ts @@ -2187,6 +2187,12 @@ export interface Page { */ bringToFront(): Promise; + /** + * Cancels an ongoing [page.pickLocator()](https://playwright.dev/docs/api/class-page#page-pick-locator) call by + * deactivating pick locator mode. If no pick locator mode is active, this method is a no-op. + */ + cancelPickLocator(): Promise; + /** * **NOTE** Use locator-based [locator.check([options])](https://playwright.dev/docs/api/class-locator#locator-check) instead. * Read more about [locators](https://playwright.dev/docs/locators). @@ -3922,6 +3928,21 @@ export interface Page { width?: string|number; }): Promise; + /** + * Enters pick locator mode where hovering over page elements highlights them and shows the corresponding locator. + * Once the user clicks an element, the mode is deactivated and the + * [Locator](https://playwright.dev/docs/api/class-locator) for the picked element is returned. + * + * **Usage** + * + * ```js + * const locator = await page.pickLocator(); + * console.log(locator); + * ``` + * + */ + pickLocator(): Promise; + /** * **NOTE** Use locator-based [locator.press(key[, options])](https://playwright.dev/docs/api/class-locator#locator-press) * instead. Read more about [locators](https://playwright.dev/docs/locators). @@ -20686,28 +20707,6 @@ export interface Inspector { data: Buffer; }) => any): this; - /** - * Cancels an ongoing - * [inspector.pickLocator()](https://playwright.dev/docs/api/class-inspector#inspector-pick-locator) call by - * deactivating pick locator mode. If no pick locator mode is active, this method is a no-op. - */ - cancelPickLocator(): Promise; - - /** - * Enters pick locator mode where hovering over page elements highlights them and shows the corresponding locator. - * Once the user clicks an element, the mode is deactivated and the - * [Locator](https://playwright.dev/docs/api/class-locator) for the picked element is returned. - * - * **Usage** - * - * ```js - * const locator = await page.inspector().pickLocator(); - * console.log(locator); - * ``` - * - */ - pickLocator(): Promise; - /** * Starts capturing screencast frames. Frames are emitted as * [inspector.on('screencastframe')](https://playwright.dev/docs/api/class-inspector#inspector-event-screencast-frame) diff --git a/tests/library/inspector/recorder-api.spec.ts b/tests/library/inspector/recorder-api.spec.ts index c0874ff83884f..75fd778003d96 100644 --- a/tests/library/inspector/recorder-api.spec.ts +++ b/tests/library/inspector/recorder-api.spec.ts @@ -152,11 +152,11 @@ test('should disable recorder', async ({ context }) => { expect(log.action('click')).toHaveLength(2); }); -test('inspector.pickLocator should return locator for picked element', async ({ page }) => { +test('page.pickLocator should return locator for picked element', async ({ page }) => { await page.setContent(``); const scriptReady = page.waitForEvent('console', msg => msg.text() === 'Recorder script ready for test'); - const pickPromise = page.inspector().pickLocator(); + const pickPromise = page.pickLocator(); await scriptReady; const box = await page.getByRole('button', { name: 'Submit' }).boundingBox(); @@ -166,15 +166,15 @@ test('inspector.pickLocator should return locator for picked element', async ({ await expect(locator).toHaveText('Submit'); }); -test('inspector.cancelPickLocator should cancel ongoing pickLocator', async ({ page }) => { +test('page.cancelPickLocator should cancel ongoing pickLocator', async ({ page }) => { await page.setContent(``); const scriptReady = page.waitForEvent('console', msg => msg.text() === 'Recorder script ready for test'); - const pickPromise = page.inspector().pickLocator(); + const pickPromise = page.pickLocator(); await scriptReady; await Promise.all([ - page.inspector().cancelPickLocator(), + page.cancelPickLocator(), expect(pickPromise).rejects.toThrow('Locator picking was cancelled'), ]); }); From b465d0419dc47fc0c488cac283f4eafb21702c80 Mon Sep 17 00:00:00 2001 From: Yury Semikhatsky Date: Tue, 10 Mar 2026 18:25:10 -0700 Subject: [PATCH 4/5] feat(api): rename Inspector to Screencast (#39607) --- docs/src/api/class-page.md | 39 +- ...class-inspector.md => class-screencast.md} | 36 +- packages/playwright-client/types/types.d.ts | 354 +++++++++--------- packages/playwright-core/src/client/api.ts | 2 +- packages/playwright-core/src/client/page.ts | 10 +- .../client/{inspector.ts => screencast.ts} | 6 +- .../src/devtools/devtoolsController.ts | 8 +- packages/playwright-core/types/types.d.ts | 354 +++++++++--------- tests/library/screencast.spec.ts | 125 +++++++ tests/library/video.spec.ts | 104 ----- 10 files changed, 530 insertions(+), 508 deletions(-) rename docs/src/api/{class-inspector.md => class-screencast.md} (52%) rename packages/playwright-core/src/client/{inspector.ts => screencast.ts} (83%) create mode 100644 tests/library/screencast.spec.ts diff --git a/docs/src/api/class-page.md b/docs/src/api/class-page.md index 10544cb6ca2a5..0300069b02d49 100644 --- a/docs/src/api/class-page.md +++ b/docs/src/api/class-page.md @@ -2607,25 +2607,6 @@ Throws for non-input elements. However, if the element is inside the `