Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
84 changes: 84 additions & 0 deletions .claude/skills/playwright-dev/library.md
Original file line number Diff line number Diff line change
Expand Up @@ -208,6 +208,8 @@ Implementations:

## Dispatcher Layer

Dispathers do not implement things, they translate protocol to the server code calls.

### Dispatcher — Base Class

```
Expand Down Expand Up @@ -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 <file>` — runs on Chromium only (fast, use during development)
- `npm run test <file>` — 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`.
8 changes: 8 additions & 0 deletions packages/playwright-core/src/client/browser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,14 @@ export class Browser extends ChannelOwner<channels.BrowserChannel> 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<void> {
await this._channel.stopServer();
}

async newPage(options: BrowserContextOptions = {}): Promise<Page> {
return await this._wrapApiCall(async () => {
const context = await this.newContext(options);
Expand Down
6 changes: 4 additions & 2 deletions packages/playwright-core/src/client/browserType.ts
Original file line number Diff line number Diff line change
Expand Up @@ -122,11 +122,12 @@ export class BrowserType extends ChannelOwner<channels.BrowserTypeChannel> imple
}

connect(options: api.ConnectOptions & { wsEndpoint: string }): Promise<Browser>;
connect(options: api.ConnectOptions & { pipeName: string }): Promise<Browser>;
connect(wsEndpoint: string, options?: api.ConnectOptions): Promise<Browser>;
async connect(optionsOrWsEndpoint: string | (api.ConnectOptions & { wsEndpoint: string }), options?: api.ConnectOptions): Promise<Browser>{
async connect(optionsOrWsEndpoint: string | (api.ConnectOptions & { wsEndpoint?: string, pipeName?: string }), options?: api.ConnectOptions): Promise<Browser>{
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);
}

Expand All @@ -137,6 +138,7 @@ export class BrowserType extends ChannelOwner<channels.BrowserTypeChannel> 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,
Expand Down
3 changes: 2 additions & 1 deletion packages/playwright-core/src/client/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -96,7 +96,8 @@ export type LaunchOptions = Omit<channels.BrowserTypeLaunchOptions, 'ignoreAllDe
export type LaunchPersistentContextOptions = Omit<LaunchOptions & BrowserContextOptions, 'storageState'>;

export type ConnectOptions = {
wsEndpoint: string,
wsEndpoint?: string,
pipeName?: string,
headers?: { [key: string]: string; };
exposeNetwork?: string,
_exposeNetwork?: string,
Expand Down
2 changes: 1 addition & 1 deletion packages/playwright-core/src/client/webSocket.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 [];
}

Expand Down
14 changes: 13 additions & 1 deletion packages/playwright-core/src/protocol/validator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down Expand Up @@ -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),
});
Expand Down
20 changes: 10 additions & 10 deletions packages/playwright-core/src/remote/playwrightConnection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<void>;
}

export class PlaywrightConnection {
private _ws: WebSocket;
private _transport: ServerTransport;
private _semaphore: Semaphore;
private _dispatcherConnection: DispatcherConnection;
private _cleanups: (() => Promise<void>)[] = [];
Expand All @@ -40,8 +40,8 @@ export class PlaywrightConnection {
private _root: DispatcherScope;
private _profileName: string;

constructor(semaphore: Semaphore, ws: WebSocket, controller: boolean, playwright: Playwright, initialize: () => Promise<PlaywrightInitializeResult>, id: string) {
this._ws = ws;
constructor(semaphore: Semaphore, transport: ServerTransport, controller: boolean, playwright: Playwright, initialize: () => Promise<PlaywrightInitializeResult>, id: string) {
this._transport = transport;
this._semaphore = semaphore;
this._id = id;
this._profileName = new Date().toISOString();
Expand All @@ -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);
Expand All @@ -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`);
Expand Down Expand Up @@ -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) {
}
}
Expand Down
92 changes: 92 additions & 0 deletions packages/playwright-core/src/remote/playwrightPipeServer.ts
Original file line number Diff line number Diff line change
@@ -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<PlaywrightConnection>();
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<void>((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<PlaywrightInitializeResult> {
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<void>(f => this._server!.close(() => f()));
this._server = undefined;
debugLogger.log('server', 'closed pipe server');
}
}
Loading
Loading