diff --git a/docs/src/api/class-browser.md b/docs/src/api/class-browser.md index 7867ce5c8ba71..74759f3ce8e34 100644 --- a/docs/src/api/class-browser.md +++ b/docs/src/api/class-browser.md @@ -295,6 +295,44 @@ testing frameworks should explicitly create [`method: Browser.newContext`] follo ### option: Browser.newPage.storageStatePath = %%-csharp-java-context-option-storage-state-path-%% * since: v1.9 +## async method: Browser.bind +* since: v1.59 +- returns: <[Object]> + - `endpoint` <[string]> + +Binds the browser to a named pipe or web socket, making it available for other clients to connect to. + +### param: Browser.bind.title +* since: v1.59 +- `title` <[string]> + +Title of the browser server, used for identification. + +### option: Browser.bind.workspaceDir +* since: v1.59 +- `workspaceDir` <[string]> + +Working directory associated with this browser server. + +### option: Browser.bind.metadata +* since: v1.59 +* langs: js +- `metadata` <[Object]<[string], [any]>> + +Additional metadata to associate with the browser server. + +### option: Browser.bind.host +* since: v1.59 +- `host` <[string]> + +Host to bind the web socket server to. When specified, a web socket server is created instead of a named pipe. + +### option: Browser.bind.port +* since: v1.59 +- `port` <[int]> + +Port to bind the web socket server to. When specified, a web socket server is created instead of a named pipe. Use `0` to let the OS pick an available port. + ## async method: Browser.removeAllListeners * since: v1.47 * langs: js @@ -382,6 +420,11 @@ This API controls [Chromium Tracing](https://www.chromium.org/developers/how-tos Returns the buffer with trace data. +## async method: Browser.unbind +* since: v1.59 + +Unbinds the browser server previously bound with [`method: Browser.bind`]. + ## method: Browser.version * since: v1.8 - returns: <[string]> diff --git a/docs/src/images/release-notes-1.59-dashboard.png b/docs/src/images/release-notes-1.59-dashboard.png new file mode 100644 index 0000000000000..b951a63c4ef18 Binary files /dev/null and b/docs/src/images/release-notes-1.59-dashboard.png differ diff --git a/docs/src/release-notes-csharp.md b/docs/src/release-notes-csharp.md index b86893b385dab..3a48b99ebf40c 100644 --- a/docs/src/release-notes-csharp.md +++ b/docs/src/release-notes-csharp.md @@ -10,11 +10,14 @@ import LiteYouTube from '@site/src/components/LiteYouTube'; ### 🎬 Screencast -New [`property: Page.screencast`] API provides a unified interface for capturing page content — both as video recordings and as real-time frame streams. +New [`property: Page.screencast`] API provides a unified interface for capturing page content — both as video recordings, annotations, overlays, and a real-time frame stream. +
Demo +
-**Video recording** — record video with precise start/stop control, as an alternative to the [`option: Browser.newContext.recordVideoDir`] option: + +**Screencast recording** — record video with precise start/stop control, as an alternative to the [`option: Browser.newContext.recordVideoDir`] option: ```csharp await page.Screencast.StartAsync(new() { Path = "video.webm" }); @@ -103,10 +106,63 @@ The resulting video serves as a receipt: chapter titles provide context, action - Method [`method: BrowserContext.isClosed`]. - Method [`method: Request.existingResponse`] returns the response without waiting. - Method [`method: Response.httpVersion`] returns the HTTP version used by the response. -- Event [`event: CDPSession.close`] for CDP sessions. - Option `Live` in [`method: Tracing.start`] for real-time trace updates. - Option `ArtifactsDir` in [`method: BrowserType.launch`] to configure the artifacts directory. +### 🔗 Interoperability + +New [`method: Browser.bind`] API makes a launched browser available for `playwright-cli`, `@playwright/mcp`, and other clients to connect to. + +**Bind a browser** — start a browser and bind it so others can connect: + +```csharp +var browser = await chromium.LaunchAsync(); +var serverInfo = await browser.BindAsync("my-session", new() { + WorkspaceDir = "/my/project", +}); +``` + +**Connect from playwright-cli** + +```bash +playwright-cli attach my-session +``` + +**Connect from playwright/mcp** + +```bash +@playwright/mcp --endpoint=my-session +``` + +**Connect from another client** + +```csharp +var browser = await chromium.ConnectAsync(serverInfo.Endpoint); +``` + +Pass `Host` and `Port` options to bind over WebSocket instead of a named pipe: + +```csharp +var serverInfo = await browser.BindAsync("my-session", new() { + Host = "localhost", + Port = 0, +}); +// serverInfo.Endpoint is a ws:// URL +``` + +Call [`method: Browser.unbind`] to stop accepting new connections. + +### 📊 Observability + +Run `playwright-cli show` to open the Dashboard that lists all bound browsers, their status, and workspace. + +
+Demo +
+ +- `playwright-cli` binds all of its browsers automatically, so you can see what your agents are doing. +- Pass `PLAYWRIGHT_DASHBOARD=1` env variable to see all `@playwright/test` browsers in the dashboard. + ### Breaking Changes ⚠️ - Removed macOS 14 support for WebKit. We recommend upgrading your macOS version, or keeping an older Playwright version. diff --git a/docs/src/release-notes-java.md b/docs/src/release-notes-java.md index 7f85d6a05c0cc..c883cf1da4733 100644 --- a/docs/src/release-notes-java.md +++ b/docs/src/release-notes-java.md @@ -10,11 +10,14 @@ import LiteYouTube from '@site/src/components/LiteYouTube'; ### 🎬 Screencast -New [`property: Page.screencast`] API provides a unified interface for capturing page content — both as video recordings and as real-time frame streams. +New [`property: Page.screencast`] API provides a unified interface for capturing page content — both as video recordings, annotations, overlays, and a real-time frame stream. +
Demo +
-**Video recording** — record video with precise start/stop control, as an alternative to the [`option: Browser.newContext.recordVideoDir`] option: + +**Screencast recording** — record video with precise start/stop control, as an alternative to the [`option: Browser.newContext.recordVideoDir`] option: ```java page.screencast().start(new Screencast.StartOptions().setPath(Paths.get("video.webm"))); @@ -103,10 +106,60 @@ The resulting video serves as a receipt: chapter titles provide context, action - Method [`method: BrowserContext.isClosed`]. - Method [`method: Request.existingResponse`] returns the response without waiting. - Method [`method: Response.httpVersion`] returns the HTTP version used by the response. -- Event [`event: CDPSession.close`] for CDP sessions. - Option `live` in [`method: Tracing.start`] for real-time trace updates. - Option `artifactsDir` in [`method: BrowserType.launch`] to configure the artifacts directory. +### 🔗 Interoperability + +New [`method: Browser.bind`] API makes a launched browser available for `playwright-cli`, `@playwright/mcp`, and other clients to connect to. + +**Bind a browser** — start a browser and bind it so others can connect: + +```java +Browser browser = chromium.launch(); +Browser.BindResult serverInfo = browser.bind("my-session", + new Browser.BindOptions().setWorkspaceDir("/my/project")); +``` + +**Connect from playwright-cli** + +```bash +playwright-cli attach my-session +``` + +**Connect from playwright/mcp** + +```bash +@playwright/mcp --endpoint=my-session +``` + +**Connect from another client** + +```java +Browser browser = chromium.connect(serverInfo.endpoint); +``` + +Pass `host` and `port` options to bind over WebSocket instead of a named pipe: + +```java +Browser.BindResult serverInfo = browser.bind("my-session", + new Browser.BindOptions().setHost("localhost").setPort(0)); +// serverInfo.endpoint is a ws:// URL +``` + +Call [`method: Browser.unbind`] to stop accepting new connections. + +### 📊 Observability + +Run `playwright-cli show` to open the Dashboard that lists all bound browsers, their status, and workspace. + +
+Demo +
+ +- `playwright-cli` binds all of its browsers automatically, so you can see what your agents are doing. +- Pass `PLAYWRIGHT_DASHBOARD=1` env variable to see all `@playwright/test` browsers in the dashboard. + ### Breaking Changes ⚠️ - Removed macOS 14 support for WebKit. We recommend upgrading your macOS version, or keeping an older Playwright version. diff --git a/docs/src/release-notes-js.md b/docs/src/release-notes-js.md index e6740866ca7bb..ddaf728492b99 100644 --- a/docs/src/release-notes-js.md +++ b/docs/src/release-notes-js.md @@ -10,11 +10,14 @@ import LiteYouTube from '@site/src/components/LiteYouTube'; ### 🎬 Screencast -New [`property: Page.screencast`] API provides a unified interface for capturing page content — both as video recordings and as real-time frame streams. +New [`property: Page.screencast`] API provides a unified interface for capturing page content — both as video recordings, annotations, overlays, and a real-time frame stream. +
Demo +
-**Video recording** — record video with precise start/stop control, as an alternative to the [`option: Browser.newContext.recordVideo`] option: + +**Screencast recording** — record video with precise start/stop control, as an alternative to the [`option: Browser.newContext.recordVideo`] option: ```js await page.screencast.start({ path: 'video.webm' }); @@ -91,9 +94,62 @@ await page.screencast.stop(); The resulting video serves as a receipt: chapter titles provide context, action annotations highlight each interaction, and the visual walkthrough is faster to review than text logs. -### 🤖 Agentic Tools +### 🔗 Interoperability + +New [`method: Browser.bind`] API makes a launched browser available for `playwright-cli`, `@playwright/mcp`, and other clients to connect to. + +**Bind a browser** — start a browser and bind it so others can connect: + +```js +const browser = await chromium.launch(); +const { endpoint } = await browser.bind('my-session', { + workspaceDir: '/my/project', +}); +``` + +**Connect from playwright-cli** + +```bash +playwright-cli attach my-session +playwright-cli -s my-session snapshot +``` + +**Connect from playwright/mcp** + +```bash +@playwright/mcp --endpoint=my-session +``` + +**Connect from another client** + +```js +const browser = await chromium.connect(endpoint); +``` + +Pass `host` and `port` options to bind over WebSocket instead of a named pipe: + +```js +const { endpoint } = await browser.bind('my-session', { + host: 'localhost', + port: 0, +}); +// endpoint is a ws:// URL +``` + +Call [`method: Browser.unbind`] to stop accepting new connections. + +### 📊 Observability + +Run `playwright-cli show` to open the Dashboard that lists all bound browsers, their status, and workspace. + +
+Demo +
+ +- `playwright-cli` binds all of its browsers automatically, so you can see what your agents are doing. +- Pass `PLAYWRIGHT_DASHBOARD=1` env variable to see all `@playwright/test` browsers in the dashboard. -#### CLI debugger +### 🐛 CLI debugger for agents Coding agents can now run `npx playwright test --debug=cli` to attach and debug tests over `playwright-cli` — perfect for automatically fixing tests in agentic workflows: @@ -116,7 +172,7 @@ $ playwright-cli --session tw-87b59e step-over - Expect "toHaveTitle" at output/tests/example.spec.ts:7 ``` -#### CLI trace analysis +### 📋 CLI trace analysis for agents Coding agents can run `npx playwright trace` to explore [Playwright Trace](./trace-viewer.md) and understand failing or flaky tests from the command line: diff --git a/docs/src/release-notes-python.md b/docs/src/release-notes-python.md index e6e0aaa54b111..3a71cb1888d1c 100644 --- a/docs/src/release-notes-python.md +++ b/docs/src/release-notes-python.md @@ -10,11 +10,14 @@ import LiteYouTube from '@site/src/components/LiteYouTube'; ### 🎬 Screencast -New [`property: Page.screencast`] API provides a unified interface for capturing page content — both as video recordings and as real-time frame streams. +New [`property: Page.screencast`] API provides a unified interface for capturing page content — both as video recordings, annotations, overlays, and a real-time frame stream. +
Demo +
-**Video recording** — record video with precise start/stop control, as an alternative to the [`option: Browser.newContext.recordVideoDir`] option: + +**Screencast recording** — record video with precise start/stop control, as an alternative to the [`option: Browser.newContext.recordVideoDir`] option: ```python page.screencast.start(path="video.webm") @@ -103,10 +106,63 @@ The resulting video serves as a receipt: chapter titles provide context, action - Method [`method: BrowserContext.isClosed`]. - Method [`method: Request.existingResponse`] returns the response without waiting. - Method [`method: Response.httpVersion`] returns the HTTP version used by the response. -- Event [`event: CDPSession.close`] for CDP sessions. - Option `live` in [`method: Tracing.start`] for real-time trace updates. - Option `artifacts_dir` in [`method: BrowserType.launch`] to configure the artifacts directory. +### 🔗 Interoperability + +New [`method: Browser.bind`] API makes a launched browser available for `playwright-cli`, `@playwright/mcp`, and other clients to connect to. + +**Bind a browser** — start a browser and bind it so others can connect: + +```python +browser = await chromium.launch() +server_info = await browser.bind("my-session", + workspace_dir="/my/project", +) +``` + +**Connect from playwright-cli** + +```bash +playwright-cli attach my-session +``` + +**Connect from playwright/mcp** + +```bash +@playwright/mcp --endpoint=my-session +``` + +**Connect from another client** + +```python +browser = await chromium.connect(server_info["endpoint"]) +``` + +Pass `host` and `port` options to bind over WebSocket instead of a named pipe: + +```python +server_info = await browser.bind("my-session", + host="localhost", + port=0, +) +# server_info["endpoint"] is a ws:// URL +``` + +Call [`method: Browser.unbind`] to stop accepting new connections. + +### 📊 Observability + +Run `playwright-cli show` to open the Dashboard that lists all bound browsers, their status, and workspace. + +
+Demo +
+ +- `playwright-cli` binds all of its browsers automatically, so you can see what your agents are doing. +- Pass `PLAYWRIGHT_DASHBOARD=1` env variable to see all `@playwright/test` browsers in the dashboard. + ### Breaking Changes ⚠️ - Removed macOS 14 support for WebKit. We recommend upgrading your macOS version, or keeping an older Playwright version. diff --git a/packages/dashboard/index.html b/packages/dashboard/index.html index 131fe41bdd2d3..c801b11ba972d 100644 --- a/packages/dashboard/index.html +++ b/packages/dashboard/index.html @@ -18,7 +18,7 @@ - Playwright Monitor + Playwright Dashboard
diff --git a/packages/playwright-client/types/types.d.ts b/packages/playwright-client/types/types.d.ts index 637fa2b03196f..2fbe9ef02a9c7 100644 --- a/packages/playwright-client/types/types.d.ts +++ b/packages/playwright-client/types/types.d.ts @@ -9782,6 +9782,36 @@ export interface Browser { */ prependListener(event: 'disconnected', listener: (browser: Browser) => any): this; + /** + * Binds the browser to a named pipe or web socket, making it available for other clients to connect to. + * @param title Title of the browser server, used for identification. + * @param options + */ + bind(title: string, options?: { + /** + * Host to bind the web socket server to. When specified, a web socket server is created instead of a named pipe. + */ + host?: string; + + /** + * Additional metadata to associate with the browser server. + */ + metadata?: { [key: string]: any; }; + + /** + * Port to bind the web socket server to. When specified, a web socket server is created instead of a named pipe. Use + * `0` to let the OS pick an available port. + */ + port?: number; + + /** + * Working directory associated with this browser server. + */ + workspaceDir?: string; + }): Promise<{ + endpoint: string; + }>; + /** * Get the browser type (chromium, firefox or webkit) that the browser belongs to. */ @@ -10412,6 +10442,12 @@ export interface Browser { */ stopTracing(): Promise; + /** + * Unbinds the browser server previously bound with + * [browser.bind(title[, options])](https://playwright.dev/docs/api/class-browser#browser-bind). + */ + unbind(): Promise; + /** * Returns the browser version. */ diff --git a/packages/playwright-core/src/client/browser.ts b/packages/playwright-core/src/client/browser.ts index e14889a3b9d80..1f19055489b37 100644 --- a/packages/playwright-core/src/client/browser.ts +++ b/packages/playwright-core/src/client/browser.ts @@ -128,12 +128,12 @@ export class Browser extends ChannelOwner implements ap return this._initializer.version; } - async _register(title: string, options: { workspaceDir?: string, metadata?: Record, wsPath?: string } = {}): Promise<{ pipeName: string }> { - const { pipeName } = await this._channel.startServer({ title, ...options }); - return { pipeName }; + async bind(title: string, options: { workspaceDir?: string, metadata?: Record, host?: string, port?: number } = {}): Promise<{ endpoint: string }> { + const { endpoint } = await this._channel.startServer({ title, ...options }); + return { endpoint }; } - async _unregister(): Promise { + async unbind(): Promise { await this._channel.stopServer(); } diff --git a/packages/playwright-core/src/protocol/validator.ts b/packages/playwright-core/src/protocol/validator.ts index 9389553c93629..a0174127d8215 100644 --- a/packages/playwright-core/src/protocol/validator.ts +++ b/packages/playwright-core/src/protocol/validator.ts @@ -659,9 +659,11 @@ scheme.BrowserStartServerParams = tObject({ title: tString, workspaceDir: tOptional(tString), metadata: tOptional(tAny), + host: tOptional(tString), + port: tOptional(tInt), }); scheme.BrowserStartServerResult = tObject({ - pipeName: tString, + endpoint: tString, }); scheme.BrowserStopServerParams = tOptional(tObject({})); scheme.BrowserStopServerResult = tOptional(tObject({})); diff --git a/packages/playwright-core/src/server/browser.ts b/packages/playwright-core/src/server/browser.ts index 830a21e4930e3..35a77fff5eeda 100644 --- a/packages/playwright-core/src/server/browser.ts +++ b/packages/playwright-core/src/server/browser.ts @@ -25,6 +25,7 @@ import { PlaywrightPipeServer } from '../remote/playwrightPipeServer'; import { PlaywrightWebSocketServer } from '../remote/playwrightWebSocketServer'; import { BrowserInfo, serverRegistry } from '../serverRegistry'; import { makeSocketPath } from './utils/fileUtils'; +import { createGuid } from '../utils'; import type * as types from './types'; import type { ProxySettings } from './types'; @@ -158,7 +159,7 @@ export abstract class Browser extends SdkObject { this._downloads.delete(uuid); } - async startServer(title: string, options: channels.BrowserStartServerOptions): Promise<{ pipeName: string }> { + async startServer(title: string, options: channels.BrowserStartServerOptions): Promise<{ endpoint: string }> { return await this._server.start(title, options); } @@ -203,15 +204,21 @@ export class BrowserServer { this._browser = browser; } - async start(title: string, options: channels.BrowserStartServerOptions): Promise<{ pipeName: string }> { + async start(title: string, options: channels.BrowserStartServerOptions): Promise<{ endpoint: string }> { if (this._isStarted) throw new Error(`Server is already started.`); this._isStarted = true; - this._pipeServer = new PlaywrightPipeServer(this._browser); - this._pipeSocketPath = await this._socketPath(); - await this._pipeServer.listen(this._pipeSocketPath); - const pipeName = this._pipeSocketPath; + let endpoint: string; + if (options.host !== undefined || options.port !== undefined) { + this._wsServer = new PlaywrightWebSocketServer(this._browser, '/'); + endpoint = await this._wsServer.listen(options.port ?? 0, options.host, createGuid()); + } else { + this._pipeServer = new PlaywrightPipeServer(this._browser); + this._pipeSocketPath = await this._socketPath(); + await this._pipeServer.listen(this._pipeSocketPath); + endpoint = this._pipeSocketPath; + } const browserInfo: BrowserInfo = { guid: this._browser.guid, @@ -221,11 +228,11 @@ export class BrowserServer { }; await serverRegistry.create(browserInfo, { title, - pipeName, + endpoint, workspaceDir: options.workspaceDir, metadata: options.metadata, }); - return { pipeName }; + return { endpoint }; } async stop() { diff --git a/packages/playwright-core/src/serverRegistry.ts b/packages/playwright-core/src/serverRegistry.ts index f1c1172c20d01..08cb2612f2db0 100644 --- a/packages/playwright-core/src/serverRegistry.ts +++ b/packages/playwright-core/src/serverRegistry.ts @@ -33,7 +33,7 @@ export type BrowserInfo = { export type EndpointInfo = { title: string; - pipeName?: string; + endpoint?: string; workspaceDir?: string; metadata?: Record; }; @@ -93,7 +93,7 @@ class ServerRegistry { playwrightLib: require.resolve('..'), title: endpoint.title, browser, - pipeName: endpoint.pipeName, + endpoint: endpoint.endpoint, workspaceDir: endpoint.workspaceDir, }; await fs.promises.writeFile(file, JSON.stringify(descriptor), 'utf-8'); @@ -137,16 +137,25 @@ class ServerRegistry { } async function canConnect(descriptor: BrowserDescriptor): Promise { - if (descriptor.pipeName) { + if (!descriptor.endpoint) + return false; + if (descriptor.endpoint.startsWith('ws://') || descriptor.endpoint.startsWith('wss://')) { return await new Promise(resolve => { - const socket = net.createConnection(descriptor.pipeName!, () => { + const url = new URL(descriptor.endpoint!); + const socket = net.createConnection(Number(url.port), url.hostname, () => { socket.destroy(); resolve(true); }); socket.on('error', () => resolve(false)); }); } - return false; + return await new Promise(resolve => { + const socket = net.createConnection(descriptor.endpoint ?? (descriptor as any).pipeName, () => { + socket.destroy(); + resolve(true); + }); + socket.on('error', () => resolve(false)); + }); } const defaultCacheDirectory = (() => { diff --git a/packages/playwright-core/src/tools/cli-client/program.ts b/packages/playwright-core/src/tools/cli-client/program.ts index 4b99b7d77ea4e..6cfc403de4200 100644 --- a/packages/playwright-core/src/tools/cli-client/program.ts +++ b/packages/playwright-core/src/tools/cli-client/program.ts @@ -28,7 +28,7 @@ import { serverRegistry } from '../../serverRegistry'; import { minimist } from './minimist'; import type { ClientInfo, SessionFile } from './registry'; -import type { BrowserDescriptor } from '../../serverRegistry'; +import type { BrowserStatus } from '../../serverRegistry'; import type { MinimistArgs } from './minimist'; type GlobalOptions = { @@ -38,7 +38,7 @@ type GlobalOptions = { }; type OpenOptions = { - attach?: string; + endpoint?: string; browser?: string; config?: string; extension?: boolean; @@ -48,7 +48,7 @@ type OpenOptions = { }; const globalOptions: (keyof (GlobalOptions & OpenOptions))[] = [ - 'attach', + 'endpoint', 'browser', 'config', 'extension', @@ -139,7 +139,7 @@ export async function program(options?: { embedderVersion?: string}) { case 'attach': { const attachTarget = args._[1]; const attachSessionName = explicitSessionName(args.session as string) ?? attachTarget; - args.attach = attachTarget; + args.endpoint = attachTarget; args.session = attachSessionName; await startSession(attachSessionName, registry, clientInfo, args); return; @@ -271,40 +271,38 @@ async function killAllDaemons(): Promise { } async function listSessions(registry: Registry, clientInfo: ClientInfo, all: boolean): Promise { - if (all) { - const entries = registry.entryMap(); - const serverEntries = await serverRegistry.list(); - if (entries.size === 0 && serverEntries.size === 0) { - console.log('No browsers found.'); - return; - } + console.log('### Browsers'); - const runningSessions = new Set(); - if (entries.size) - console.log('### Browsers'); - for (const [workspace, list] of entries) - await gcAndPrintSessions(clientInfo, list.map(entry => new Session(entry)), `${path.relative(process.cwd(), workspace) || '/'}:`, runningSessions); - - // Filter out server entries that already have an attached session. - const filteredServerEntries = new Map(); - for (const [workspace, list] of serverEntries) { - const unattached = list.filter(d => !runningSessions.has(d.title)); - if (unattached.length) - filteredServerEntries.set(workspace, unattached); - } + let count = 0; + const runningSessions = new Set(); + const entries = registry.entryMap(); + for (const [workspace, list] of entries) { + if (!all && workspace !== clientInfo.workspaceDir) + continue; + count += await gcAndPrintSessions(clientInfo, list.map(entry => new Session(entry)), all ? `${path.relative(process.cwd(), workspace) || '/'}:` : undefined, runningSessions); + } - if (filteredServerEntries.size) { - if (entries.size) - console.log(''); - console.log('### Browser servers available for attach'); - } - for (const [workspace, list] of filteredServerEntries) - await gcAndPrintBrowserSessions(workspace, list); - } else { - console.log('### Browsers'); - const entries = registry.entries(clientInfo); - await gcAndPrintSessions(clientInfo, entries.map(entry => new Session(entry))); + // Filter out server entries that already have an attached session. + const serverEntries = await serverRegistry.list(); + const filteredServerEntries = new Map(); + for (const [workspace, list] of serverEntries) { + if (!all && workspace !== clientInfo.workspaceDir) + continue; + const unattached = list.filter(d => !runningSessions.has(d.title)); + if (unattached.length) + filteredServerEntries.set(workspace, unattached); } + + if (filteredServerEntries.size) { + if (count) + console.log(''); + console.log('### Browser servers available for attach'); + } + for (const [workspace, list] of filteredServerEntries) + count += await gcAndPrintBrowserSessions(workspace, list); + + if (!count) + console.log(' (no browsers)'); } async function gcAndPrintSessions(clientInfo: ClientInfo, sessions: Session[], header?: string, runningSessions?: Set) { @@ -332,13 +330,12 @@ async function gcAndPrintSessions(clientInfo: ClientInfo, sessions: Session[], h for (const session of stopped) console.log(await renderSessionStatus(clientInfo, session)); - if (running.length === 0 && stopped.length === 0) - console.log(' (no browsers)'); + return running.length + stopped.length; } -async function gcAndPrintBrowserSessions(workspace: string, list: BrowserDescriptor[]) { +async function gcAndPrintBrowserSessions(workspace: string, list: BrowserStatus[]): Promise { if (!list.length) - return; + return 0; if (workspace) console.log(`${path.relative(process.cwd(), workspace) || '/'}:`); @@ -348,12 +345,15 @@ async function gcAndPrintBrowserSessions(workspace: string, list: BrowserDescrip text.push(`- browser "${descriptor.title}":`); text.push(` - browser: ${descriptor.browser.browserName}`); text.push(` - version: v${descriptor.playwrightVersion}`); + text.push(` - status: ${descriptor.canConnect ? 'open' : 'closed'}`); + if (descriptor.browser.userDataDir) + text.push(` - data-dir: ${descriptor.browser.userDataDir}`); + else + text.push(` - data-dir: `); text.push(` - run \`playwright-cli attach "${descriptor.title}"\` to attach`); console.log(text.join('\n')); } - - if (!list.length) - console.log(' (no browsers)'); + return list.length; } async function renderSessionStatus(clientInfo: ClientInfo, session: Session) { diff --git a/packages/playwright-core/src/tools/cli-client/session.ts b/packages/playwright-core/src/tools/cli-client/session.ts index c8b2a475b3856..f80f0f427d332 100644 --- a/packages/playwright-core/src/tools/cli-client/session.ts +++ b/packages/playwright-core/src/tools/cli-client/session.ts @@ -143,8 +143,8 @@ export class Session { args.push(`--profile=${cliArgs.profile}`); if (cliArgs.config) args.push(`--config=${cliArgs.config}`); - if (cliArgs.attach || process.env.PLAYWRIGHT_CLI_SESSION) - args.push(`--attach=${cliArgs.attach || process.env.PLAYWRIGHT_CLI_SESSION}`); + if (cliArgs.endpoint || process.env.PLAYWRIGHT_CLI_SESSION) + args.push(`--endpoint=${cliArgs.endpoint || process.env.PLAYWRIGHT_CLI_SESSION}`); const child = spawn(process.execPath, args, { detached: true, @@ -196,8 +196,8 @@ export class Session { child.stdout!.destroy(); child.unref(); - if (cliArgs['attach']) { - console.log(`### Session \`${sessionName}\` created, attached to \`${cliArgs['attach']}\`.`); + if (cliArgs['endpoint']) { + console.log(`### Session \`${sessionName}\` created, attached to \`${cliArgs['endpoint']}\`.`); console.log(`Run commands with: playwright-cli --session=${sessionName} `); } else { console.log(`### Browser \`${sessionName}\` opened with pid ${child.pid}.`); diff --git a/packages/playwright-core/src/tools/cli-daemon/program.ts b/packages/playwright-core/src/tools/cli-daemon/program.ts index 5d6bdc30a801f..8a7743922e8f0 100644 --- a/packages/playwright-core/src/tools/cli-daemon/program.ts +++ b/packages/playwright-core/src/tools/cli-daemon/program.ts @@ -35,7 +35,7 @@ program.argument('[session-name]', 'name of the session to create or connect to' .option('--persistent', 'use a persistent browser context') .option('--profile ', 'path to the user data dir') .option('--config ', 'path to the config file; by default uses .playwright/cli.config.json in the project directory and ~/.playwright/cli.config.json as global config') - .option('--attach ', 'attach to a running Playwright browser by name or endpoint') + .option('--endpoint ', 'attach to a running Playwright browser endpoint') .option('--init-workspace', 'initialize workspace') .option('--init-skills ', 'install skills for the given agent type ("claude" or "agents")') diff --git a/packages/playwright-core/src/tools/dashboard/dashboardApp.ts b/packages/playwright-core/src/tools/dashboard/dashboardApp.ts index 4bb1ab8bb39f1..1ea5a736e5ca7 100644 --- a/packages/playwright-core/src/tools/dashboard/dashboardApp.ts +++ b/packages/playwright-core/src/tools/dashboard/dashboardApp.ts @@ -296,4 +296,9 @@ async function main() { }); } +process.on('unhandledRejection', error => { + // eslint-disable-next-line no-console + console.error('Unhandled promise rejection:', error); +}); + void main(); diff --git a/packages/playwright-core/src/tools/mcp/browserFactory.ts b/packages/playwright-core/src/tools/mcp/browserFactory.ts index f1343536e447b..dfc1c153efdf8 100644 --- a/packages/playwright-core/src/tools/mcp/browserFactory.ts +++ b/packages/playwright-core/src/tools/mcp/browserFactory.ts @@ -33,8 +33,6 @@ import type { FullConfig } from './config'; import type { ClientInfo } from '../utils/mcp/server'; // eslint-disable-next-line no-restricted-imports import type { Playwright } from '../../client/playwright'; -// eslint-disable-next-line no-restricted-imports -import type { Browser } from '../../client/browser'; import type { BrowserInfo } from '../../serverRegistry'; type ClientInfoEx = ClientInfo & { @@ -255,5 +253,5 @@ function throwBrowserIsNotInstalledError(config: FullConfig): never { async function startServer(browser: playwright.Browser, clientInfo: ClientInfoEx) { if (clientInfo.sessionName) - await (browser as Browser)._register(clientInfo.sessionName, { workspaceDir: clientInfo.workspaceDir }); + await browser.bind(clientInfo.sessionName, { workspaceDir: clientInfo.workspaceDir }); } diff --git a/packages/playwright-core/src/tools/mcp/config.ts b/packages/playwright-core/src/tools/mcp/config.ts index 68eae08a60fff..d95b689056152 100644 --- a/packages/playwright-core/src/tools/mcp/config.ts +++ b/packages/playwright-core/src/tools/mcp/config.ts @@ -47,6 +47,7 @@ export type CLIOptions = { config?: string; consoleLevel?: 'error' | 'warning' | 'info' | 'debug'; device?: string; + endpoint?: string; extension?: boolean; executablePath?: string; grantPermissions?: string[]; @@ -138,6 +139,7 @@ export async function resolveCLIConfigForCLI(daemonProfilesDir: string, sessionN } const daemonOverrides = configFromCLIOptions({ + endpoint: options.endpoint, config: options.config, browser: options.browser, headless: options.headed ? false : undefined, @@ -145,7 +147,6 @@ export async function resolveCLIConfigForCLI(daemonProfilesDir: string, sessionN userDataDir: options.profile, snapshotMode: 'full', }); - daemonOverrides.browser!.remoteEndpoint = options.attach; const envOverrides = configFromEnv(env); const configFile = daemonOverrides.configFile ?? envOverrides.configFile; @@ -299,6 +300,7 @@ function configFromCLIOptions(cliOptions: CLIOptions): Config & { configFile?: s cdpTimeout: cliOptions.cdpTimeout, initPage: cliOptions.initPage, initScript: cliOptions.initScript, + remoteEndpoint: cliOptions.endpoint, }, extension: cliOptions.extension, server: { diff --git a/packages/playwright-core/src/tools/mcp/program.ts b/packages/playwright-core/src/tools/mcp/program.ts index b11d63ffb97bc..9c6e020a11798 100644 --- a/packages/playwright-core/src/tools/mcp/program.ts +++ b/packages/playwright-core/src/tools/mcp/program.ts @@ -48,6 +48,7 @@ export function decorateMCPCommand(command: Command) { .option('--device ', 'device to emulate, for example: "iPhone 15"') .option('--executable-path ', 'path to the browser executable.') .option('--extension', 'Connect to a running browser instance (Edge/Chrome only). Requires the "Playwright MCP Bridge" browser extension to be installed.') + .option('--endpoint ', 'Bound browser endpoint to connect to.') .option('--grant-permissions ', 'List of permissions to grant to the browser context, for example "geolocation", "clipboard-read", "clipboard-write".', commaSeparatedList) .option('--headless', 'run browser in headless mode, headed by default') .option('--host ', 'host to bind server to. Default is localhost. Use 0.0.0.0 to bind to all interfaces.') diff --git a/packages/playwright-core/src/tools/utils/connect.ts b/packages/playwright-core/src/tools/utils/connect.ts index 647cd3c8f914d..915c4061ea0c0 100644 --- a/packages/playwright-core/src/tools/utils/connect.ts +++ b/packages/playwright-core/src/tools/utils/connect.ts @@ -20,5 +20,6 @@ import type { BrowserDescriptor } from '../../serverRegistry'; export async function connectToBrowserAcrossVersions(descriptor: BrowserDescriptor): Promise { const pw = require(descriptor.playwrightLib); const browserType = pw[descriptor.browser.browserName] as playwright.BrowserType; - return await browserType.connect(descriptor.pipeName!); + // eslint-disable-next-line no-restricted-syntax + return await browserType.connect(descriptor.endpoint ?? (descriptor as any).pipeName); } diff --git a/packages/playwright-core/types/types.d.ts b/packages/playwright-core/types/types.d.ts index 637fa2b03196f..2fbe9ef02a9c7 100644 --- a/packages/playwright-core/types/types.d.ts +++ b/packages/playwright-core/types/types.d.ts @@ -9782,6 +9782,36 @@ export interface Browser { */ prependListener(event: 'disconnected', listener: (browser: Browser) => any): this; + /** + * Binds the browser to a named pipe or web socket, making it available for other clients to connect to. + * @param title Title of the browser server, used for identification. + * @param options + */ + bind(title: string, options?: { + /** + * Host to bind the web socket server to. When specified, a web socket server is created instead of a named pipe. + */ + host?: string; + + /** + * Additional metadata to associate with the browser server. + */ + metadata?: { [key: string]: any; }; + + /** + * Port to bind the web socket server to. When specified, a web socket server is created instead of a named pipe. Use + * `0` to let the OS pick an available port. + */ + port?: number; + + /** + * Working directory associated with this browser server. + */ + workspaceDir?: string; + }): Promise<{ + endpoint: string; + }>; + /** * Get the browser type (chromium, firefox or webkit) that the browser belongs to. */ @@ -10412,6 +10442,12 @@ export interface Browser { */ stopTracing(): Promise; + /** + * Unbinds the browser server previously bound with + * [browser.bind(title[, options])](https://playwright.dev/docs/api/class-browser#browser-bind). + */ + unbind(): Promise; + /** * Returns the browser version. */ diff --git a/packages/playwright/src/index.ts b/packages/playwright/src/index.ts index c85a161a28fbf..0f61235bc7af9 100644 --- a/packages/playwright/src/index.ts +++ b/packages/playwright/src/index.ts @@ -101,7 +101,7 @@ const playwrightFixtures: Fixtures = ({ playwright._defaultLaunchOptions = undefined; }, { scope: 'worker', auto: true, box: true }], - browser: [async ({ playwright, browserName, _browserOptions, connectOptions }, use) => { + browser: [async ({ playwright, browserName, _browserOptions, connectOptions }, use, workerInfo) => { if (!['chromium', 'firefox', 'webkit'].includes(browserName)) throw new Error(`Unexpected browserName "${browserName}", must be one of "chromium", "firefox" or "webkit"`); @@ -121,6 +121,8 @@ const playwrightFixtures: Fixtures = ({ } const browser = await playwright[browserName].launch(); + if (process.env.PLAYWRIGHT_DASHBOARD) + await browser.bind(`worker-${workerInfo.parallelIndex}`); await use(browser); await browser.close({ reason: 'Test ended.' }); }, { scope: 'worker', timeout: 0 }], diff --git a/packages/playwright/src/mcp/test/browserBackend.ts b/packages/playwright/src/mcp/test/browserBackend.ts index 72a16f0e5201f..668fcc6bd0409 100644 --- a/packages/playwright/src/mcp/test/browserBackend.ts +++ b/packages/playwright/src/mcp/test/browserBackend.ts @@ -20,7 +20,6 @@ import { stripAnsiEscapes } from 'playwright-core/lib/utils'; import type * as tools from 'playwright-core/lib/tools/exports'; import type * as playwright from '../../../index'; import type { TestInfoImpl } from '../../worker/testInfo'; -import type { Browser } from '../../../../playwright-core/src/client/browser'; export type BrowserMCPRequest = { initialize?: { clientInfo: tools.ClientInfo }, @@ -117,7 +116,7 @@ export async function runDaemonForContext(testInfo: TestInfoImpl, context: playw return false; const sessionName = `tw-${crypto.randomBytes(3).toString('hex')}`; - await (context.browser() as Browser)!._register(sessionName, { workspaceDir: testInfo.project.testDir }); + await context.browser()!.bind(sessionName, { workspaceDir: testInfo.project.testDir }); /* eslint-disable-next-line no-console */ console.log([ diff --git a/packages/protocol/src/channels.d.ts b/packages/protocol/src/channels.d.ts index 1ed3c901eb67c..323143bb1af9d 100644 --- a/packages/protocol/src/channels.d.ts +++ b/packages/protocol/src/channels.d.ts @@ -1193,13 +1193,17 @@ export type BrowserStartServerParams = { title: string, workspaceDir?: string, metadata?: any, + host?: string, + port?: number, }; export type BrowserStartServerOptions = { workspaceDir?: string, metadata?: any, + host?: string, + port?: number, }; export type BrowserStartServerResult = { - pipeName: string, + endpoint: string, }; export type BrowserStopServerParams = {}; export type BrowserStopServerOptions = {}; diff --git a/packages/protocol/src/protocol.yml b/packages/protocol/src/protocol.yml index bc3183ae8c3ef..c11dad3b8f4e1 100644 --- a/packages/protocol/src/protocol.yml +++ b/packages/protocol/src/protocol.yml @@ -1064,8 +1064,10 @@ Browser: title: string workspaceDir: string? metadata: json? + host: string? + port: int? returns: - pipeName: string + endpoint: string stopServer: title: Stop server diff --git a/tests/library/browser-server.spec.ts b/tests/library/browser-server.spec.ts index 11fc81fd5999d..1205e1b646fbf 100644 --- a/tests/library/browser-server.spec.ts +++ b/tests/library/browser-server.spec.ts @@ -26,22 +26,22 @@ it.beforeEach(({}, testInfo) => { }); it('should start and stop pipe server', async ({ browserType, browser }) => { - const serverInfo = await (browser as any)._register('default', {}); + const serverInfo = await browser.bind('default', {}); expect(serverInfo).toEqual(expect.objectContaining({ - pipeName: expect.stringMatching(/browser@/), + endpoint: expect.stringMatching(/browser@/), })); - const browser2 = await (browserType as any).connect(serverInfo.pipeName); + const browser2 = await (browserType as any).connect(serverInfo.endpoint); const page = await browser2.newPage(); await page.goto('data:text/html,

Hello via pipe

'); expect(await page.locator('h1').textContent()).toBe('Hello via pipe'); await page.close(); await browser2.close(); - await (browser as any)._unregister(); + await browser.unbind(); }); it('should write descriptor on start and remove on stop', async ({ browser }) => { - const serverInfo = await (browser as any)._register('my-title', { wsPath: 'test' } as any); + const serverInfo = await browser.bind('my-title', { wsPath: 'test' } as any); const registryDir = it.info().outputPath('registry'); const fileName = fs.readdirSync(registryDir)[0]; @@ -52,13 +52,13 @@ it('should write descriptor on start and remove on stop', async ({ browser }) => expect(descriptor.playwrightVersion).toBeTruthy(); expect(descriptor.playwrightLib).toBeTruthy(); expect(descriptor.browser.browserName).toBeTruthy(); - expect(descriptor.pipeName).toBe(serverInfo.pipeName); + expect(descriptor.endpoint).toBe(serverInfo.endpoint); if (process.platform !== 'win32') - expect(fs.existsSync(serverInfo.pipeName)).toBe(true); + expect(fs.existsSync(serverInfo.endpoint)).toBe(true); - await (browser as any)._unregister(); + await browser.unbind(); expect(fs.existsSync(file)).toBe(false); if (process.platform !== 'win32') - expect(fs.existsSync(serverInfo.pipeName)).toBe(false); + expect(fs.existsSync(serverInfo.endpoint)).toBe(false); }); diff --git a/tests/mcp/cli-session.spec.ts b/tests/mcp/cli-session.spec.ts index 11eca7547d5f4..5feeee7065df2 100644 --- a/tests/mcp/cli-session.spec.ts +++ b/tests/mcp/cli-session.spec.ts @@ -288,20 +288,23 @@ test.describe('browser server', () => { test('list browser servers', async ({ cli, mcpBrowser }) => { const browserName = mcpBrowser.replace('chrome', 'chromium'); await using browser = await playwright[browserName].launch({ headless: true }); - await (browser as any)._register('foobar', { workspaceDir: 'workspace1' }); + await browser.bind('foobar', { workspaceDir: 'workspace1' }); const { output } = await cli('list', '--all'); - expect(output).toBe(`### Browser servers available for attach + expect(output).toBe(`### Browsers +### Browser servers available for attach workspace1: - browser "foobar": - browser: ${/* FIX browser._options */ mcpBrowser.replace('chrome', 'chromium')} - version: ${version} + - status: open + - data-dir: - run \`playwright-cli attach "foobar"\` to attach`); }); test('attach to browser server', async ({ cli, mcpBrowser }) => { const browserName = mcpBrowser.replace('chrome', 'chromium'); await using browser = await playwright[browserName].launch({ headless: true }); - await (browser as any)._register('foobar', { workspaceDir: 'workspace1' }); + await browser.bind('foobar', { workspaceDir: 'workspace1' }); const page = await browser.newPage(); await page.setContent('My Page'); const { output: openOutput } = await cli('attach', 'foobar'); @@ -320,7 +323,7 @@ workspace1: test('fail to attach to browser server without contexts', async ({ cli, mcpBrowser }) => { const browserName = mcpBrowser.replace('chrome', 'chromium'); await using browser = await playwright[browserName].launch({ headless: true }); - await (browser as any)._register('foobar', { workspaceDir: 'workspace1' }); + await browser.bind('foobar', { workspaceDir: 'workspace1' }); const { error } = await cli('attach', 'foobar'); expect(error).toContain('Error: unable to connect to a browser that does not have any contexts'); }); @@ -330,7 +333,7 @@ workspace1: await using browser = await playwright[browserName].launch({ headless: true }); const page = await browser.newPage(); await page.setContent('Env Page

Hello from env

'); - await (browser as any)._register('foobar', { workspaceDir: 'workspace1' }); + await browser.bind('foobar', { workspaceDir: 'workspace1' }); const { output: openOutput, snapshot } = await cli('open', { env: { PLAYWRIGHT_CLI_SESSION: 'foobar' } }); expect(openOutput).toContain('### Browser `foobar` opened with pid'); expect(openOutput).toContain('Env Page'); @@ -348,7 +351,7 @@ workspace1: test('attach with session alias', async ({ cli, mcpBrowser }) => { const browserName = mcpBrowser.replace('chrome', 'chromium'); await using browser = await playwright[browserName].launch({ headless: true }); - await (browser as any)._register('foobar', { workspaceDir: 'workspace1' }); + await browser.bind('foobar', { workspaceDir: 'workspace1' }); const page = await browser.newPage(); await page.setContent('Alias Page'); const { output: openOutput } = await cli('attach', 'foobar', '--session=mybrowser'); @@ -361,16 +364,19 @@ workspace1: const browserName = mcpBrowser.replace('chrome', 'chromium'); await using browser = await playwright[browserName].launch({ headless: true }); await browser.newPage(); - await (browser as any)._register('foobar', { workspaceDir: 'workspace1' }); + await browser.bind('foobar', { workspaceDir: 'workspace1' }); const { output: openOutput } = await cli('attach', 'foobar'); expect(openOutput).toContain('Session `foobar` created, attached to `foobar`'); await cli('-s', 'foobar', 'close'); const { output: listOutput } = await cli('list', '--all'); - expect(listOutput).toBe(`### Browser servers available for attach + expect(listOutput).toBe(`### Browsers +### Browser servers available for attach workspace1: - browser \"foobar\": - browser: ${/* FIX browser._options */ mcpBrowser.replace('chrome', 'chromium')} - version: ${version} + - status: open + - data-dir: - run \`playwright-cli attach \"foobar\"\` to attach`); }); }); diff --git a/tests/mcp/config-resolve.spec.ts b/tests/mcp/config-resolve.spec.ts index 14476722d7d10..f30681c6dc04c 100644 --- a/tests/mcp/config-resolve.spec.ts +++ b/tests/mcp/config-resolve.spec.ts @@ -347,7 +347,7 @@ test.describe('resolveCLIConfigForCLI - isolated and userDataDir', () => { }); test('not isolated when --attach is set', async ({}, testInfo) => { - const config = await resolveCLI(testInfo.outputPath('profiles'), 'default', { attach: 'ws://localhost:1234' }); + const config = await resolveCLI(testInfo.outputPath('profiles'), 'default', { endpoint: 'ws://localhost:1234' }); expect(config.browser.isolated).toBe(false); });