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.
+
+
-**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.
+
+
+
+
+
+- `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.
+
+
-**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.
+
+
+
+
+
+- `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.
+
+
-**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.
+
+
+
+
+
+- `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.
+
+
-**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.
+
+
+
+
+
+- `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 PageHello 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);
});