From 9b381196706e62d0418389bd01448b9212b4242d Mon Sep 17 00:00:00 2001 From: Pavel Feldman Date: Thu, 26 Feb 2026 10:54:18 -0800 Subject: [PATCH] chore: optionally expose the browser server --- .claude/skills/playwright-dev/library.md | 84 +++++++++++++ .../playwright-core/src/client/browser.ts | 8 ++ .../playwright-core/src/client/browserType.ts | 6 +- packages/playwright-core/src/client/types.ts | 3 +- .../playwright-core/src/client/webSocket.ts | 2 +- .../playwright-core/src/protocol/validator.ts | 14 ++- .../src/remote/playwrightConnection.ts | 20 +-- .../src/remote/playwrightPipeServer.ts | 92 ++++++++++++++ .../src/remote/playwrightServer.ts | 13 +- .../src/remote/playwrightWebSocketServer.ts | 71 +++++++++++ .../src/remote/serverTransport.ts | 114 ++++++++++++++++++ packages/playwright-core/src/server/DEPS.list | 3 + .../playwright-core/src/server/browser.ts | 100 ++++++++++++++- .../server/dispatchers/browserDispatcher.ts | 8 ++ .../dispatchers/localUtilsDispatcher.ts | 40 +++++- .../src/server/registry/index.ts | 30 ++--- .../src/utils/isomorphic/protocolMetainfo.ts | 2 + packages/protocol/src/channels.d.ts | 23 +++- packages/protocol/src/protocol.yml | 16 ++- tests/library/browser-server.spec.ts | 78 ++++++++++++ 20 files changed, 688 insertions(+), 39 deletions(-) create mode 100644 packages/playwright-core/src/remote/playwrightPipeServer.ts create mode 100644 packages/playwright-core/src/remote/playwrightWebSocketServer.ts create mode 100644 packages/playwright-core/src/remote/serverTransport.ts create mode 100644 tests/library/browser-server.spec.ts diff --git a/.claude/skills/playwright-dev/library.md b/.claude/skills/playwright-dev/library.md index bc4aa63fb85cb..e619a381f86d9 100644 --- a/.claude/skills/playwright-dev/library.md +++ b/.claude/skills/playwright-dev/library.md @@ -208,6 +208,8 @@ Implementations: ## Dispatcher Layer +Dispathers do not implement things, they translate protocol to the server code calls. + ### Dispatcher — Base Class ``` @@ -332,3 +334,85 @@ CLIENT: 2. **Adoption**: `dispatcher.adopt(child)` sends `__adopt__` → client reparents the `ChannelOwner` 3. **Disposal**: `dispatcher._dispose()` recursively disposes children → sends `__dispose__` → client removes `ChannelOwner` from maps 4. **GC**: Server-side `maybeDisposeStaleDispatchers()` evicts oldest dispatchers per bucket when limits exceeded + +## Testing: tests/library vs tests/page + +Tests live in two directories under `tests/`, each with distinct scope and fixtures. + +### tests/library — API and Feature Tests + +Tests the **Playwright public API surface**, browser lifecycle, and feature-level behavior. Uses `browserTest` fixtures which provide direct access to `browser`, `browserType`, `context`, and `contextFactory`. + +```typescript +import { browserTest as test, expect } from '../config/browserTest'; + +test('should create new page', async ({ browser }) => { + const page = await browser.newPage(); + expect(browser.contexts().length).toBe(1); + await page.close(); +}); +``` + +**What belongs here:** +- Browser and BrowserType API (`launch`, `connect`, `version`, `newContext`) +- BrowserContext API (cookies, storage state, permissions, proxy, CSP, geolocation, network interception at context level) +- Browser-specific features (`chromium/` for CDP, tracing, extensions, JS/CSS coverage, OOPIF; `firefox/` for launcher specifics) +- Protocol and channel tests +- Inspector, codegen, and recorder features (`inspector/`) +- Event system tests (`events/`) +- Unit tests for internal utilities (`unit/`) + +**Key fixtures** (from `browserTest`): `browser`, `browserType`, `context`, `contextFactory`, `launchPersistent`, `createUserDataDir`, `startRemoteServer`, `pageWithHar`. + +### tests/page — Page Interaction Tests + +Tests **user-facing page interactions**: clicking, typing, navigation, locators, assertions, and DOM operations. Uses `pageTest` fixtures which provide a ready-to-use `page` plus test servers. + +```typescript +import { test as it, expect } from './pageTest'; + +it('should click button', async ({ page, server }) => { + await page.goto(server.PREFIX + '/input/button.html'); + await page.locator('button').click(); + expect(await page.evaluate(() => window['result'])).toBe('Clicked'); +}); +``` + +**What belongs here:** +- Locator API (click, fill, type, select, query, filtering, convenience methods) +- ElementHandle interactions (click, screenshot, selection, bounding box) +- Expect/assertion matchers (boolean, text, value, accessibility) +- Page navigation (`goto`, `waitForNavigation`, `waitForURL`) +- Frame evaluation and hierarchy +- Request/response interception at page level +- JSHandle operations +- Screenshot and visual comparison tests + +**Key fixtures** (from `pageTest`/`serverFixtures`): `page`, `server`, `httpsServer`, `proxyServer`, `asset`. + +### Decision Rule + +| Question | → Directory | +|----------|-------------| +| Does it test browser/context lifecycle or launch options? | `tests/library` | +| Does it test a browser-specific protocol feature (CDP, etc.)? | `tests/library` | +| Does it test user interaction with page content (click, type, assert)? | `tests/page` | +| Does it test locators, selectors, or DOM queries? | `tests/page` | +| Does the test need direct `browser` or `browserType` access? | `tests/library` | +| Does the test just need a `page` and a test server? | `tests/page` | + +### Running Tests + +- `npm run ctest ` — runs on Chromium only (fast, use during development) +- `npm run test ` — runs on all browsers (Chromium, Firefox, WebKit) + +Examples: +```bash +npm run ctest tests/library/browser-context-cookies.spec.ts +npm run ctest tests/page/locator-click.spec.ts +npm run test tests/library/browser-context-cookies.spec.ts +``` + +### Configuration + +Both directories share a single config at `tests/library/playwright.config.ts`. It creates separate projects (`{browserName}-library` and `{browserName}-page`) pointing to their respective `testDir`. diff --git a/packages/playwright-core/src/client/browser.ts b/packages/playwright-core/src/client/browser.ts index f4b803891587b..d21c88924e782 100644 --- a/packages/playwright-core/src/client/browser.ts +++ b/packages/playwright-core/src/client/browser.ts @@ -126,6 +126,14 @@ export class Browser extends ChannelOwner implements ap return this._initializer.version; } + async _startServer(title: string, options: { wsPath?: string, workspaceDir?: string } = {}): Promise<{ wsEndpoint?: string, pipeName?: string }> { + return await this._channel.startServer({ title, ...options }); + } + + async _stopServer(): Promise { + await this._channel.stopServer(); + } + async newPage(options: BrowserContextOptions = {}): Promise { return await this._wrapApiCall(async () => { const context = await this.newContext(options); diff --git a/packages/playwright-core/src/client/browserType.ts b/packages/playwright-core/src/client/browserType.ts index 0a41e3492a1cb..ac4a19cd63f24 100644 --- a/packages/playwright-core/src/client/browserType.ts +++ b/packages/playwright-core/src/client/browserType.ts @@ -122,11 +122,12 @@ export class BrowserType extends ChannelOwner imple } connect(options: api.ConnectOptions & { wsEndpoint: string }): Promise; + connect(options: api.ConnectOptions & { pipeName: string }): Promise; connect(wsEndpoint: string, options?: api.ConnectOptions): Promise; - async connect(optionsOrWsEndpoint: string | (api.ConnectOptions & { wsEndpoint: string }), options?: api.ConnectOptions): Promise{ + async connect(optionsOrWsEndpoint: string | (api.ConnectOptions & { wsEndpoint?: string, pipeName?: string }), options?: api.ConnectOptions): Promise{ if (typeof optionsOrWsEndpoint === 'string') return await this._connect({ ...options, wsEndpoint: optionsOrWsEndpoint }); - assert(optionsOrWsEndpoint.wsEndpoint, 'options.wsEndpoint is required'); + assert(optionsOrWsEndpoint.wsEndpoint || optionsOrWsEndpoint.pipeName, 'Either options.wsEndpoint or options.pipeName is required'); return await this._connect(optionsOrWsEndpoint); } @@ -137,6 +138,7 @@ export class BrowserType extends ChannelOwner imple const headers = { 'x-playwright-browser': this.name(), ...params.headers }; const connectParams: channels.LocalUtilsConnectParams = { wsEndpoint: params.wsEndpoint, + pipeName: params.pipeName, headers, exposeNetwork: params.exposeNetwork ?? params._exposeNetwork, slowMo: params.slowMo, diff --git a/packages/playwright-core/src/client/types.ts b/packages/playwright-core/src/client/types.ts index b9e2423613350..920cc3ea98fff 100644 --- a/packages/playwright-core/src/client/types.ts +++ b/packages/playwright-core/src/client/types.ts @@ -96,7 +96,8 @@ export type LaunchOptions = Omit; export type ConnectOptions = { - wsEndpoint: string, + wsEndpoint?: string, + pipeName?: string, headers?: { [key: string]: string; }; exposeNetwork?: string, _exposeNetwork?: string, diff --git a/packages/playwright-core/src/client/webSocket.ts b/packages/playwright-core/src/client/webSocket.ts index 39ce48892b74e..0e56a2401bab6 100644 --- a/packages/playwright-core/src/client/webSocket.ts +++ b/packages/playwright-core/src/client/webSocket.ts @@ -88,7 +88,7 @@ class WebSocketTransport implements Transport { private _ws: WebSocket | undefined; async connect(params: channels.LocalUtilsConnectParams) { - this._ws = new window.WebSocket(params.wsEndpoint); + this._ws = new window.WebSocket(params.wsEndpoint!); return []; } diff --git a/packages/playwright-core/src/protocol/validator.ts b/packages/playwright-core/src/protocol/validator.ts index b395a6cad7e99..86bd0a197b8e1 100644 --- a/packages/playwright-core/src/protocol/validator.ts +++ b/packages/playwright-core/src/protocol/validator.ts @@ -314,7 +314,8 @@ scheme.LocalUtilsHarUnzipParams = tObject({ }); scheme.LocalUtilsHarUnzipResult = tOptional(tObject({})); scheme.LocalUtilsConnectParams = tObject({ - wsEndpoint: tString, + wsEndpoint: tOptional(tString), + pipeName: tOptional(tString), headers: tOptional(tAny), exposeNetwork: tOptional(tString), slowMo: tOptional(tFloat), @@ -646,6 +647,17 @@ scheme.BrowserContextEvent = tObject({ context: tChannel(['BrowserContext']), }); scheme.BrowserCloseEvent = tOptional(tObject({})); +scheme.BrowserStartServerParams = tObject({ + title: tString, + wsPath: tOptional(tString), + workspaceDir: tOptional(tString), +}); +scheme.BrowserStartServerResult = tObject({ + wsEndpoint: tOptional(tString), + pipeName: tOptional(tString), +}); +scheme.BrowserStopServerParams = tOptional(tObject({})); +scheme.BrowserStopServerResult = tOptional(tObject({})); scheme.BrowserCloseParams = tObject({ reason: tOptional(tString), }); diff --git a/packages/playwright-core/src/remote/playwrightConnection.ts b/packages/playwright-core/src/remote/playwrightConnection.ts index 8741a46fa4b65..53d7c46521a90 100644 --- a/packages/playwright-core/src/remote/playwrightConnection.ts +++ b/packages/playwright-core/src/remote/playwrightConnection.ts @@ -24,14 +24,14 @@ import { debugLogger } from '../server/utils/debugLogger'; import { PlaywrightDispatcherOptions } from '../server/dispatchers/playwrightDispatcher'; import type { DispatcherScope, Playwright } from '../server'; -import type { WebSocket } from '../utilsBundle'; +import type { ServerTransport } from './serverTransport'; export interface PlaywrightInitializeResult extends PlaywrightDispatcherOptions { dispose?(): Promise; } export class PlaywrightConnection { - private _ws: WebSocket; + private _transport: ServerTransport; private _semaphore: Semaphore; private _dispatcherConnection: DispatcherConnection; private _cleanups: (() => Promise)[] = []; @@ -40,8 +40,8 @@ export class PlaywrightConnection { private _root: DispatcherScope; private _profileName: string; - constructor(semaphore: Semaphore, ws: WebSocket, controller: boolean, playwright: Playwright, initialize: () => Promise, id: string) { - this._ws = ws; + constructor(semaphore: Semaphore, transport: ServerTransport, controller: boolean, playwright: Playwright, initialize: () => Promise, id: string) { + this._transport = transport; this._semaphore = semaphore; this._id = id; this._profileName = new Date().toISOString(); @@ -51,16 +51,16 @@ export class PlaywrightConnection { this._dispatcherConnection = new DispatcherConnection(); this._dispatcherConnection.onmessage = async message => { await lock; - if (ws.readyState !== ws.CLOSING) { + if (!transport.isClosed()) { const messageString = JSON.stringify(message); if (debugLogger.isEnabled('server:channel')) debugLogger.log('server:channel', `[${this._id}] ${monotonicTime() * 1000} SEND ► ${messageString}`); if (debugLogger.isEnabled('server:metadata')) this.logServerMetadata(message, messageString, 'SEND'); - ws.send(messageString); + transport.send(messageString); } }; - ws.on('message', async (message: string) => { + transport.on('message', async (message: string) => { await lock; const messageString = Buffer.from(message).toString(); const jsonMessage = JSON.parse(messageString); @@ -71,8 +71,8 @@ export class PlaywrightConnection { this._dispatcherConnection.dispatch(jsonMessage); }); - ws.on('close', () => this._onDisconnect()); - ws.on('error', (error: Error) => this._onDisconnect(error)); + transport.on('close', () => this._onDisconnect()); + transport.on('error', (error: Error) => this._onDisconnect(error)); if (controller) { debugLogger.log('server', `[${this._id}] engaged reuse controller mode`); @@ -138,7 +138,7 @@ export class PlaywrightConnection { return; debugLogger.log('server', `[${this._id}] force closing connection: ${reason?.reason || ''} (${reason?.code || 0})`); try { - this._ws.close(reason?.code, reason?.reason); + this._transport.close(reason); } catch (e) { } } diff --git a/packages/playwright-core/src/remote/playwrightPipeServer.ts b/packages/playwright-core/src/remote/playwrightPipeServer.ts new file mode 100644 index 0000000000000..03c4eae5ecff9 --- /dev/null +++ b/packages/playwright-core/src/remote/playwrightPipeServer.ts @@ -0,0 +1,92 @@ +/** + * Copyright (c) Microsoft Corporation. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import net from 'net'; +import fs from 'fs'; + +import { PlaywrightConnection } from './playwrightConnection'; +import { SocketServerTransport } from './serverTransport'; +import { debugLogger } from '../server/utils/debugLogger'; +import { Browser } from '../server/browser'; +import { Semaphore } from '../utils'; + +import type { PlaywrightInitializeResult } from './playwrightConnection'; + +export class PlaywrightPipeServer { + private _server: net.Server | undefined; + private _connections = new Set(); + private _connectionId = 0; + private _browser: Browser; + + constructor(browser: Browser) { + this._browser = browser; + browser.on(Browser.Events.Disconnected, () => this.close()); + } + + async listen(pipeName: string) { + // Clean up stale socket file on Unix (not needed for Windows named pipes). + if (!pipeName.startsWith('\\\\.\\pipe\\')) { + try { + fs.unlinkSync(pipeName); + } catch { + } + } + + this._server = net.createServer(socket => { + const id = String(++this._connectionId); + debugLogger.log('server', `[${id}] pipe client connected`); + const transport = new SocketServerTransport(socket); + const connection = new PlaywrightConnection( + new Semaphore(1), + transport, + false, + this._browser.attribution.playwright, + () => this._initPreLaunchedBrowserMode(id), + id, + ); + this._connections.add(connection); + transport.on('close', () => this._connections.delete(connection)); + }); + + await new Promise((resolve, reject) => { + this._server!.listen(pipeName, () => resolve()); + this._server!.on('error', reject); + }); + + debugLogger.log('server', `Pipe server listening at ${pipeName}`); + } + + private async _initPreLaunchedBrowserMode(id: string): Promise { + debugLogger.log('server', `[${id}] engaged pre-launched (browser) pipe mode`); + return { + preLaunchedBrowser: this._browser, + sharedBrowser: true, + denyLaunch: true, + }; + } + + async close() { + if (!this._server) + return; + debugLogger.log('server', 'closing pipe server'); + for (const connection of this._connections) + await connection.close({ code: 1001, reason: 'Server closing' }); + this._connections.clear(); + await new Promise(f => this._server!.close(() => f())); + this._server = undefined; + debugLogger.log('server', 'closed pipe server'); + } +} diff --git a/packages/playwright-core/src/remote/playwrightServer.ts b/packages/playwright-core/src/remote/playwrightServer.ts index e901bf508b7a5..1f3994422c5d1 100644 --- a/packages/playwright-core/src/remote/playwrightServer.ts +++ b/packages/playwright-core/src/remote/playwrightServer.ts @@ -15,6 +15,7 @@ */ import { PlaywrightConnection, PlaywrightInitializeResult } from './playwrightConnection'; +import { WebSocketServerTransport } from './serverTransport'; import { createPlaywright } from '../server/playwright'; import { Semaphore } from '../utils/isomorphic/semaphore'; import { DEFAULT_PLAYWRIGHT_LAUNCH_TIMEOUT } from '../utils/isomorphic/time'; @@ -113,7 +114,7 @@ export class PlaywrightServer { throw new Error(`Unknown connect filter: ${connectFilter}`); return new PlaywrightConnection( browserSemaphore, - ws, + new WebSocketServerTransport(ws), false, this._playwright, () => this._initConnectMode(id, connectFilter, browserName, launchOptions), @@ -124,7 +125,7 @@ export class PlaywrightServer { if (url.searchParams.has('debug-controller')) { return new PlaywrightConnection( controllerSemaphore, - ws, + new WebSocketServerTransport(ws), true, this._playwright, async () => { throw new Error('shouldnt be used'); }, @@ -133,7 +134,7 @@ export class PlaywrightServer { } return new PlaywrightConnection( reuseBrowserSemaphore, - ws, + new WebSocketServerTransport(ws), false, this._playwright, () => this._initReuseBrowsersMode(browserName, launchOptions, id), @@ -145,7 +146,7 @@ export class PlaywrightServer { if (this._options.preLaunchedBrowser) { return new PlaywrightConnection( browserSemaphore, - ws, + new WebSocketServerTransport(ws), false, this._playwright, () => this._initPreLaunchedBrowserMode(id), @@ -155,7 +156,7 @@ export class PlaywrightServer { return new PlaywrightConnection( browserSemaphore, - ws, + new WebSocketServerTransport(ws), false, this._playwright, () => this._initPreLaunchedAndroidMode(id), @@ -165,7 +166,7 @@ export class PlaywrightServer { return new PlaywrightConnection( browserSemaphore, - ws, + new WebSocketServerTransport(ws), false, this._playwright, () => this._initLaunchBrowserMode(browserName, proxyValue, launchOptions, id), diff --git a/packages/playwright-core/src/remote/playwrightWebSocketServer.ts b/packages/playwright-core/src/remote/playwrightWebSocketServer.ts new file mode 100644 index 0000000000000..0c8dd1c2622f4 --- /dev/null +++ b/packages/playwright-core/src/remote/playwrightWebSocketServer.ts @@ -0,0 +1,71 @@ +/** + * Copyright (c) Microsoft Corporation. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { PlaywrightConnection } from './playwrightConnection'; +import { WebSocketServerTransport } from './serverTransport'; +import { debugLogger } from '../server/utils/debugLogger'; +import { Browser } from '../server/browser'; +import { Semaphore } from '../utils'; +import { WSServer } from '../server/utils/wsServer'; + +import type { PlaywrightInitializeResult } from './playwrightConnection'; + +export class PlaywrightWebSocketServer { + private _wsServer: WSServer; + private _browser: Browser; + + constructor(browser: Browser, path: string) { + this._browser = browser; + browser.on(Browser.Events.Disconnected, () => this.close()); + + const semaphore = new Semaphore(Infinity); + this._wsServer = new WSServer({ + onRequest: (request, response) => { + response.end('Running'); + }, + onUpgrade: () => undefined, + onHeaders: () => {}, + onConnection: (request, url, ws, id) => { + debugLogger.log('server', `[${id}] ws client connected`); + return new PlaywrightConnection( + semaphore, + new WebSocketServerTransport(ws), + false, + this._browser.attribution.playwright, + () => this._initPreLaunchedBrowserMode(id), + id, + ); + }, + }); + } + + private async _initPreLaunchedBrowserMode(id: string): Promise { + debugLogger.log('server', `[${id}] engaged pre-launched (browser) ws mode`); + return { + preLaunchedBrowser: this._browser, + sharedBrowser: true, + denyLaunch: true, + }; + } + + async listen(port: number = 0, hostname?: string, path?: string): Promise { + return await this._wsServer.listen(port, hostname, path || '/'); + } + + async close() { + await this._wsServer.close(); + } +} diff --git a/packages/playwright-core/src/remote/serverTransport.ts b/packages/playwright-core/src/remote/serverTransport.ts new file mode 100644 index 0000000000000..cf05157ee14cd --- /dev/null +++ b/packages/playwright-core/src/remote/serverTransport.ts @@ -0,0 +1,114 @@ +/** + * Copyright (c) Microsoft Corporation. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { EventEmitter } from 'events'; + +import type { WebSocket } from '../utilsBundle'; +import type net from 'net'; + +export interface ServerTransport { + send(message: string): void; + close(reason?: { code: number, reason: string }): void; + on(event: 'message', handler: (message: string) => void): void; + on(event: 'close', handler: () => void): void; + on(event: 'error', handler: (error: Error) => void): void; + isClosed(): boolean; +} + +export class WebSocketServerTransport implements ServerTransport { + private _ws: WebSocket; + + constructor(ws: WebSocket) { + this._ws = ws; + } + + send(message: string): void { + this._ws.send(message); + } + + close(reason?: { code: number, reason: string }): void { + this._ws.close(reason?.code, reason?.reason); + } + + on(event: 'message', handler: (message: string) => void): void; + on(event: 'close', handler: () => void): void; + on(event: 'error', handler: (error: Error) => void): void; + on(event: string, handler: (...args: any[]) => void): void { + this._ws.on(event, handler); + } + + isClosed(): boolean { + return this._ws.readyState === this._ws.CLOSING || this._ws.readyState === this._ws.CLOSED; + } +} + +export class SocketServerTransport extends EventEmitter implements ServerTransport { + private _socket: net.Socket; + private _closed = false; + private _pendingBuffers: Buffer[] = []; + + constructor(socket: net.Socket) { + super(); + this._socket = socket; + + socket.on('data', (buffer: Buffer) => this._dispatch(buffer)); + socket.on('close', () => { + this._closed = true; + super.emit('close'); + }); + socket.on('error', (error: Error) => { + super.emit('error', error); + }); + } + + send(message: string): void { + if (this._closed) + return; + this._socket.write(message); + this._socket.write('\0'); + } + + close(reason?: { code: number, reason: string }): void { + if (this._closed) + return; + this._closed = true; + this._socket.end(); + } + + isClosed(): boolean { + return this._closed; + } + + private _dispatch(buffer: Buffer) { + let end = buffer.indexOf('\0'); + if (end === -1) { + this._pendingBuffers.push(buffer); + return; + } + this._pendingBuffers.push(buffer.slice(0, end)); + const message = Buffer.concat(this._pendingBuffers).toString(); + super.emit('message', message); + + let start = end + 1; + end = buffer.indexOf('\0', start); + while (end !== -1) { + super.emit('message', buffer.toString(undefined, start, end)); + start = end + 1; + end = buffer.indexOf('\0', start); + } + this._pendingBuffers = [buffer.slice(start)]; + } +} diff --git a/packages/playwright-core/src/server/DEPS.list b/packages/playwright-core/src/server/DEPS.list index 011d1a4c3d58c..279bef3ef86a1 100644 --- a/packages/playwright-core/src/server/DEPS.list +++ b/packages/playwright-core/src/server/DEPS.list @@ -24,3 +24,6 @@ ./electron/ ./firefox/ ./webkit/ + +[browser.ts] +../remote/ diff --git a/packages/playwright-core/src/server/browser.ts b/packages/playwright-core/src/server/browser.ts index d974cbe7e44dd..d49db1634e405 100644 --- a/packages/playwright-core/src/server/browser.ts +++ b/packages/playwright-core/src/server/browser.ts @@ -14,12 +14,20 @@ * limitations under the License. */ +import fs from 'fs'; +import path from 'path'; +import os from 'os'; + import { Artifact } from './artifact'; import { BrowserContext, validateBrowserContextOptions } from './browserContext'; import { Download } from './download'; import { SdkObject } from './instrumentation'; import { Page } from './page'; import { ClientCertificatesProxy } from './socksClientCertificatesInterceptor'; +import { PlaywrightPipeServer } from '../remote/playwrightPipeServer'; +import { PlaywrightWebSocketServer } from '../remote/playwrightWebSocketServer'; +import { createGuid } from './utils/crypto'; +import { defaultRegistryDirectory } from './registry'; import type * as types from './types'; import type { ProxySettings } from './types'; @@ -29,7 +37,6 @@ import type { ChildProcess } from 'child_process'; import type { Language } from '../utils'; import type { Progress } from './progress'; - export interface BrowserProcess { onclose?: ((exitCode: number | null, signal: string | null) => void); process?: ChildProcess; @@ -72,12 +79,14 @@ export abstract class Browser extends SdkObject { private _contextForReuse: { context: BrowserContext, hash: string } | undefined; _closeReason: string | undefined; _isCollocatedWithServer: boolean = true; + private _server: BrowserServer; constructor(parent: SdkObject, options: BrowserOptions) { super(parent, 'browser'); this.attribution.browser = this; this.options = options; this.instrumentation.onBrowserOpen(this); + this._server = new BrowserServer(this); } abstract doCreateNewContext(options: types.BrowserContextOptions): Promise; @@ -164,11 +173,20 @@ export abstract class Browser extends SdkObject { return video?.artifact; } + async startServer(title: string, options: { workspaceDir?: string, wsPath?: string, pipeName?: string }): Promise<{ wsEndpoint?: string, pipeName?: string }> { + return await this._server.start(title, options); + } + + async stopServer() { + await this._server.stop(); + } + _didClose() { for (const context of this.contexts()) context._browserClosed(); if (this._defaultContext) this._defaultContext._browserClosed(); + this.stopServer().catch(() => {}); this.emit(Browser.Events.Disconnected); this.instrumentation.onBrowserClose(this); } @@ -188,3 +206,83 @@ export abstract class Browser extends SdkObject { await this.options.browserProcess.kill(); } } + +const packageVersion = require('../../package.json').version; + +export class BrowserServer { + private _browser: Browser; + private _pipeServer?: PlaywrightPipeServer; + private _wsServer?: PlaywrightWebSocketServer; + private _pipeSocketPath?: string; + private _isStarted = false; + + constructor(browser: Browser) { + this._browser = browser; + } + + async start(title: string, options: { workspaceDir?: string, wsPath?: string }): Promise<{ wsEndpoint?: string, pipeName?: string }> { + if (this._isStarted) + throw new Error(`Server is already started.`); + + const result: { wsEndpoint?: string, pipeName?: string } = {}; + this._pipeServer = new PlaywrightPipeServer(this._browser); + this._pipeSocketPath = await this._socketPath(); + await this._pipeServer.listen(this._pipeSocketPath); + result.pipeName = this._pipeSocketPath; + + if (options.wsPath) { + const path = options.wsPath ? (options.wsPath.startsWith('/') ? options.wsPath : `/${options.wsPath}`) : `/${createGuid()}`; + this._wsServer = new PlaywrightWebSocketServer(this._browser, path); + result.wsEndpoint = await this._wsServer.listen(0); + } + + await this._createDescriptor(title, result); + return result; + } + + async stop() { + await this._deleteDescriptor(); + await this._pipeServer?.close(); + await this._wsServer?.close(); + this._pipeServer = undefined; + this._wsServer = undefined; + } + + private async _createDescriptor(title: string, result: { wsEndpoint?: string, pipeName?: string, workspaceDir?: string }) { + const file = this._descriptorPath(); + await fs.promises.mkdir(path.dirname(file), { recursive: true }); + const descriptor = { + version: packageVersion, + title, + browser: { + name: this._browser.options.name, + channel: this._browser.options.channel, + version: this._browser.version(), + }, + wsEndpoint: result.wsEndpoint ? result.wsEndpoint : undefined, + pipeName: result.pipeName ? result.pipeName : undefined, + workspaceDir: result.workspaceDir, + }; + await fs.promises.writeFile(file, JSON.stringify(descriptor), 'utf-8'); + } + + private async _deleteDescriptor() { + const file = this._descriptorPath(); + await fs.promises.unlink(file).catch(() => {}); + if (this._pipeSocketPath && process.platform !== 'win32') + await fs.promises.unlink(this._pipeSocketPath).catch(() => {}); + } + + private _descriptorPath() { + return path.join(defaultRegistryDirectory, 'browsers', this._browser.guid); + } + + private async _socketPath() { + const socketName = `${this._browser.guid}.sock`; + if (process.platform === 'win32') + return `\\\\.\\pipe\\${socketName}`; + const socketsDir = process.env.PLAYWRIGHT_BROWSER_SOCKETS_DIR || path.join(os.tmpdir(), 'playwright-browsers'); + await fs.promises.mkdir(socketsDir, { recursive: true }); + return path.join(socketsDir, socketName); + } +} diff --git a/packages/playwright-core/src/server/dispatchers/browserDispatcher.ts b/packages/playwright-core/src/server/dispatchers/browserDispatcher.ts index bda9e1f42650d..a87f7af5c0caa 100644 --- a/packages/playwright-core/src/server/dispatchers/browserDispatcher.ts +++ b/packages/playwright-core/src/server/dispatchers/browserDispatcher.ts @@ -134,6 +134,14 @@ export class BrowserDispatcher extends Dispatcher { + return await this._object.startServer(params.title, params); + } + + async stopServer(params: channels.BrowserStopServerParams, progress: Progress): Promise { + await this._object.stopServer(); + } + async cleanupContexts() { await Promise.all(Array.from(this._isolatedContexts).map(context => context.close({ reason: 'Global context cleanup (connection terminated)' }))); } diff --git a/packages/playwright-core/src/server/dispatchers/localUtilsDispatcher.ts b/packages/playwright-core/src/server/dispatchers/localUtilsDispatcher.ts index 94d0332b47a6e..655aa5df1cfba 100644 --- a/packages/playwright-core/src/server/dispatchers/localUtilsDispatcher.ts +++ b/packages/playwright-core/src/server/dispatchers/localUtilsDispatcher.ts @@ -14,12 +14,14 @@ * limitations under the License. */ +import net from 'net'; import { Dispatcher } from './dispatcher'; import { SdkObject } from '../../server/instrumentation'; import * as localUtils from '../localUtils'; import { getUserAgent } from '../utils/userAgent'; import { deviceDescriptors as descriptors } from '../deviceDescriptors'; import { JsonPipeDispatcher } from '../dispatchers/jsonPipeDispatcher'; +import { PipeTransport } from '../pipeTransport'; import { Progress } from '../progress'; import { SocksInterceptor } from '../socksInterceptor'; import { WebSocketTransport } from '../transport'; @@ -82,12 +84,18 @@ export class LocalUtilsDispatcher extends Dispatcher { + if (params.pipeName) + return await this._connectOverPipe(params, progress); + return await this._connectOverWebSocket(params, progress); + } + + private async _connectOverWebSocket(params: channels.LocalUtilsConnectParams, progress: Progress): Promise { const wsHeaders = { 'User-Agent': getUserAgent(), 'x-playwright-proxy': params.exposeNetwork ?? '', ...params.headers, }; - const wsEndpoint = await urlToWSEndpoint(progress, params.wsEndpoint); + const wsEndpoint = await urlToWSEndpoint(progress, params.wsEndpoint!); const transport = await WebSocketTransport.connect(progress, wsEndpoint, { headers: wsHeaders, followRedirects: true, debugLogHeader: 'x-playwright-debug-log' }); const socksInterceptor = new SocksInterceptor(transport, params.exposeNetwork, params.socksProxyRedirectPortForTest); @@ -118,6 +126,36 @@ export class LocalUtilsDispatcher extends Dispatcher { + const socket = await new Promise((resolve, reject) => { + const conn = net.connect(params.pipeName!, () => resolve(conn)); + conn.on('error', reject); + }); + const transport = new PipeTransport(socket, socket); + const pipe = new JsonPipeDispatcher(this); + transport.onmessage = json => { + const cb = () => { + try { + pipe.dispatch(json); + } catch (e) { + transport.close(); + } + }; + if (params.slowMo) + setTimeout(cb, params.slowMo); + else + cb(); + }; + pipe.on('message', message => { + transport.send(message); + }); + transport.onclose = (reason?: string) => { + pipe.wasClosed(reason); + }; + pipe.on('close', () => socket.end()); + return { pipe, headers: [] }; + } + async globToRegex(params: channels.LocalUtilsGlobToRegexParams, progress: Progress): Promise { const regex = resolveGlobToRegexPattern(params.baseURL, params.glob, params.webSocketUrl); return { regex }; diff --git a/packages/playwright-core/src/server/registry/index.ts b/packages/playwright-core/src/server/registry/index.ts index ab205b45d22ec..d9e3dab66f1c5 100644 --- a/packages/playwright-core/src/server/registry/index.ts +++ b/packages/playwright-core/src/server/registry/index.ts @@ -452,26 +452,28 @@ const DOWNLOAD_PATHS: Record = { }, }; +export const defaultCacheDirectory = (() => { + if (process.platform === 'linux') + return process.env.XDG_CACHE_HOME || path.join(os.homedir(), '.cache'); + if (process.platform === 'darwin') + return path.join(os.homedir(), 'Library', 'Caches'); + if (process.platform === 'win32') + return process.env.LOCALAPPDATA || path.join(os.homedir(), 'AppData', 'Local'); + throw new Error('Unsupported platform: ' + process.platform); +})(); + +export const defaultRegistryDirectory = path.join(defaultCacheDirectory, 'ms-playwright'); + export const registryDirectory = (() => { let result: string; const envDefined = getFromENV('PLAYWRIGHT_BROWSERS_PATH'); - if (envDefined === '0') { + if (envDefined === '0') result = path.join(__dirname, '..', '..', '..', '.local-browsers'); - } else if (envDefined) { + else if (envDefined) result = envDefined; - } else { - let cacheDirectory: string; - if (process.platform === 'linux') - cacheDirectory = process.env.XDG_CACHE_HOME || path.join(os.homedir(), '.cache'); - else if (process.platform === 'darwin') - cacheDirectory = path.join(os.homedir(), 'Library', 'Caches'); - else if (process.platform === 'win32') - cacheDirectory = process.env.LOCALAPPDATA || path.join(os.homedir(), 'AppData', 'Local'); - else - throw new Error('Unsupported platform: ' + process.platform); - result = path.join(cacheDirectory, 'ms-playwright'); - } + else + result = defaultRegistryDirectory; if (!path.isAbsolute(result)) { // It is important to resolve to the absolute path: diff --git a/packages/playwright-core/src/utils/isomorphic/protocolMetainfo.ts b/packages/playwright-core/src/utils/isomorphic/protocolMetainfo.ts index f21f37956abc6..9990b7f57ef00 100644 --- a/packages/playwright-core/src/utils/isomorphic/protocolMetainfo.ts +++ b/packages/playwright-core/src/utils/isomorphic/protocolMetainfo.ts @@ -51,6 +51,8 @@ export const methodMetainfo = new Map; + stopServer(params?: BrowserStopServerParams, progress?: Progress): Promise; close(params: BrowserCloseParams, progress?: Progress): Promise; killForTests(params?: BrowserKillForTestsParams, progress?: Progress): Promise; defaultUserAgentForTest(params?: BrowserDefaultUserAgentForTestParams, progress?: Progress): Promise; @@ -1168,6 +1173,22 @@ export type BrowserContextEvent = { context: BrowserContextChannel, }; export type BrowserCloseEvent = {}; +export type BrowserStartServerParams = { + title: string, + wsPath?: string, + workspaceDir?: string, +}; +export type BrowserStartServerOptions = { + wsPath?: string, + workspaceDir?: string, +}; +export type BrowserStartServerResult = { + wsEndpoint?: string, + pipeName?: string, +}; +export type BrowserStopServerParams = {}; +export type BrowserStopServerOptions = {}; +export type BrowserStopServerResult = void; export type BrowserCloseParams = { reason?: string, }; diff --git a/packages/protocol/src/protocol.yml b/packages/protocol/src/protocol.yml index 24880fc5947db..57027a0e743cb 100644 --- a/packages/protocol/src/protocol.yml +++ b/packages/protocol/src/protocol.yml @@ -705,7 +705,8 @@ LocalUtils: connect: internal: true parameters: - wsEndpoint: string + wsEndpoint: string? + pipeName: string? headers: json? exposeNetwork: string? slowMo: float? @@ -1026,6 +1027,19 @@ Browser: commands: + startServer: + title: Start server + parameters: + title: string + wsPath: string? + workspaceDir: string? + returns: + wsEndpoint: string? + pipeName: string? + + stopServer: + title: Stop server + close: title: Close browser parameters: diff --git a/tests/library/browser-server.spec.ts b/tests/library/browser-server.spec.ts new file mode 100644 index 0000000000000..b460b8f81a9d7 --- /dev/null +++ b/tests/library/browser-server.spec.ts @@ -0,0 +1,78 @@ +/** + * Copyright (c) Microsoft Corporation. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import fs from 'fs'; +import path from 'path'; + +import { browserTest as it, expect } from '../config/browserTest'; +import { defaultRegistryDirectory } from '../../packages/playwright-core/lib/server/registry'; + +it.skip(({ mode }) => mode !== 'default'); + +function descriptorPath(browser: any) { + return path.join(defaultRegistryDirectory, 'browsers', browser._guid); +} + +it('should start and stop pipe server', async ({ browserType, browser }) => { + const serverInfo = await (browser as any)._startServer('default', {}); + expect(serverInfo).toEqual(expect.objectContaining({ + pipeName: expect.stringMatching(/browser@.*\.sock/), + })); + + const browser2 = await (browserType as any).connect(serverInfo); + 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 browser2.close(); + await (browser as any)._stopServer(); +}); + +it('should start and stop ws server', async ({ browserType, browser }) => { + const serverInfo = await (browser as any)._startServer('default', { wsPath: 'test' }); + expect(serverInfo).toEqual(expect.objectContaining({ + pipeName: expect.stringMatching(/browser@.*\.sock/), + wsEndpoint: expect.stringMatching(/^ws:\/\//), + })); + + const browser2 = await browserType.connect(serverInfo.wsEndpoint); + const page = await browser2.newPage(); + await page.goto('data:text/html,

Hello

'); + expect(await page.locator('h1').textContent()).toBe('Hello'); + await browser2.close(); + await (browser as any)._stopServer(); +}); + +it('should write descriptor on start and remove on stop', async ({ browser }) => { + const file = descriptorPath(browser); + expect(fs.existsSync(file)).toBe(false); + + const serverInfo = await (browser as any)._startServer('my-title', { wsPath: 'test' }); + + const descriptor = JSON.parse(fs.readFileSync(file, 'utf-8')); + expect(descriptor.title).toBe('my-title'); + expect(descriptor.version).toBeTruthy(); + expect(descriptor.browser.name).toBeTruthy(); + expect(descriptor.wsEndpoint).toBe(serverInfo.wsEndpoint); + expect(descriptor.pipeName).toBe(serverInfo.pipeName); + + if (process.platform !== 'win32') + expect(fs.existsSync(serverInfo.pipeName)).toBe(true); + + await (browser as any)._stopServer(); + expect(fs.existsSync(file)).toBe(false); + if (process.platform !== 'win32') + expect(fs.existsSync(serverInfo.pipeName)).toBe(false); +});