# Playwright Library Architecture: Client, Server, and Dispatchers Playwright uses a client-server architecture connected by a protocol layer. The client provides the public API, the server performs actual browser automation, and dispatchers bridge the two over an RPC channel. ## Package Layout ``` packages/protocol/src/ protocol.yml — RPC protocol definition (source of truth) channels.d.ts — generated TypeScript channel interfaces callMetadata.d.ts — call metadata types packages/playwright-core/src/ client/ — public API objects (ChannelOwner subclasses) server/ — browser automation implementation (SdkObject subclasses) server/dispatchers/ — protocol bridge (Dispatcher subclasses) protocol/ — validators (generated + primitives) utils/isomorphic/ — shared code used by both client and server protocolMetainfo.ts — generated method metadata (flags, titles) ``` ## Dependency Rules (DEPS.list) Each directory has a `DEPS.list` constraining its imports. These are enforced by `npm run flint`. **client/** can import from: - `../protocol/` — validators and channel types - `../utils/isomorphic` — shared utilities **server/** can import from: - `../protocol/`, `../utils/`, `../utils/isomorphic/`, `../utilsBundle.ts` - `./` (own directory), `./codegen/`, `./isomorphic/`, `./har/`, `./recorder/`, `./registry/`, `./utils/` - Only `playwright.ts` can import browser engines (`./chromium/`, `./firefox/`, `./webkit/`, `./bidi/`, `./android/`, `./electron/`) - Only `devtoolsController.ts` can additionally import `./chromium/` **server/dispatchers/** can import from: - `../../protocol/`, `../../utils/`, `../../utils/isomorphic/` - `../**` — all server modules **Key rule:** Client code NEVER imports server code. Server code NEVER imports client code. They communicate only through the protocol. ## Protocol Layer ### protocol.yml Defines all RPC interfaces, commands (methods), events, and types. Example: ```yaml Page: type: interface extends: EventTarget initializer: mainFrame: Frame viewportSize: { type: object?, properties: { width: int, height: int } } commands: goto: parameters: url: string timeout: float waitUntil: LifecycleEvent? returns: response: Response? events: close: {} navigated: url: string name: string ``` ### Code Generation Running `node utils/generate_channels.js` (or via watch) produces: - `packages/protocol/src/channels.d.ts` — TypeScript types: `PageChannel`, `PageGotoParams`, `PageGotoResult`, `PageInitializer`, event types - `packages/playwright-core/src/protocol/validator.ts` — runtime validators: `scheme.PageGotoParams = tObject({...})` - `packages/playwright-core/src/utils/isomorphic/protocolMetainfo.ts` — method flags (slowMo, snapshot, etc.) ### Wire Format ``` Client → Server (RPC call): { id, guid, method, params, metadata? } Server → Client (response): { id, result } or { id, error, log? } Server → Client (event): { guid, method, params } Server → Client (lifecycle): { guid, method: '__create__'|'__adopt__'|'__dispose__', params } ``` Object references are serialized as `{ guid: "object-guid" }` and resolved by validators. ## Client Layer ### ChannelOwner — Base Class Every client-side API object (Page, Frame, Browser, etc.) extends `ChannelOwner`: ``` packages/playwright-core/src/client/channelOwner.ts ``` Key properties: - `_connection: Connection` — the RPC connection - `_channel: T` — Proxy that intercepts method calls and sends RPC messages - `_guid: string` — unique identifier matching the server-side object - `_type: string` — type name (e.g., 'Page', 'Frame') - `_parent: ChannelOwner` — parent in the object tree - `_objects: Map` — child objects - `_initializer` — initial state received from server on creation How `_channel` works: It's a Proxy. When you call `this._channel.goto(params)`: 1. Proxy intercepts the `goto` property access 2. Finds the validator for `PageGotoParams` 3. Returns an async function that validates params, wraps in `_wrapApiCall`, and calls `_connection.sendMessageToServer()` Event subscription optimization: `_eventToSubscriptionMapping` maps JS event names to protocol subscription events. When the first listener is added, calls `updateSubscription(event, true)` on the channel. When last listener is removed, calls `updateSubscription(event, false)`. This way the server only sends events that have listeners. ### Connection ``` packages/playwright-core/src/client/connection.ts ``` Manages the client-server transport: - `_objects: Map` — all live remote objects by GUID - `_callbacks: Map` — pending RPC calls by message ID - `sendMessageToServer(object, method, params, apiZone)` — sends RPC call, returns promise - `dispatch(message)` — handles incoming messages: - Response (has `id`): resolves/rejects the matching callback - `__create__`: instantiates ChannelOwner subclass via factory switch - `__adopt__`: reparents a child object - `__dispose__`: disposes object and all children - Event (has `method`): emits on the object's `_channel` ### Representative Client Classes | Class | File | Key delegation | |-------|------|----------------| | `Playwright` | `playwright.ts` | Root object; owns `chromium`, `firefox`, `webkit` BrowserTypes | | `BrowserType` | `browserType.ts` | `launch()` → `_channel.launch()` | | `Browser` | `browser.ts` | `newContext()` → `_channel.newContext()` | | `BrowserContext` | `browserContext.ts` | Owns pages, routes, tracing, cookies | | `Page` | `page.ts` | Delegates most calls to `_mainFrame`; owns keyboard/mouse/touchscreen | | `Frame` | `frame.ts` | `goto()`, `click()`, `evaluate()` → `_channel.*` | | `Locator` | `locator.ts` | Delegates to `Frame` methods with selector + `strict: true` | | `ElementHandle` | `elementHandle.ts` | DOM element reference | ### Public API Exports `packages/playwright-core/src/client/api.ts` exports all public classes. ## Server Layer ### SdkObject — Base Class Every server-side domain object extends `SdkObject`: ``` packages/playwright-core/src/server/instrumentation.ts ``` Key properties: - `guid: string` — unique identifier (shared with client-side ChannelOwner) - `attribution: Attribution` — ownership chain: `{ playwright, browserType?, browser?, context?, page?, frame? }` - `instrumentation: Instrumentation` — hooks for tracing, debugging, test runner integration Attribution is inherited from parent on construction. Instrumentation hooks include: `onBeforeCall`, `onAfterCall`, `onBeforeInputAction`, `onCallLog`, `onPageOpen/Close`, `onBrowserOpen/Close`, `onDialog`, `onDownload`. ### Key Server Classes | Class | File | Purpose | |-------|------|---------| | `Playwright` | `playwright.ts` | Root entry point; creates BrowserTypes | | `BrowserType` | `browserType.ts` | Launches browser processes | | `Browser` | `browser.ts` | Abstract base; owns BrowserContexts | | `BrowserContext` | `browserContext.ts` | Isolation boundary; owns pages, cookies, routes | | `Page` | `page.ts` | Owns FrameManager, workers; delegates to `PageDelegate` | | `FrameManager` | `frames.ts` | Manages frame hierarchy | | `Frame` | `frames.ts` | Navigation, DOM queries, JavaScript evaluation | | `ElementHandle` | `dom.ts` | DOM element operations | | `ProgressController` | `progress.ts` | Wraps async operations with timeout/cancellation/logging | ### PageDelegate Pattern `Page` delegates browser-specific operations to a `PageDelegate` interface: ```typescript interface PageDelegate { navigateFrame(frame, url, referer): Promise; takeScreenshot(progress, format, ...): Promise; adoptElementHandle(handle, to): Promise; // ... more browser-specific operations } ``` Implementations: - `packages/playwright-core/src/server/chromium/crPage.ts` — uses CDP - `packages/playwright-core/src/server/firefox/ffPage.ts` - `packages/playwright-core/src/server/webkit/wkPage.ts` ### Browser Engine Directories | Directory | Protocol | Key files | |-----------|----------|-----------| | `chromium/` | Chrome DevTools Protocol (CDP) | `crBrowser.ts`, `crPage.ts`, `crConnection.ts` | | `firefox/` | Firefox internal protocol | `ffBrowser.ts`, `ffPage.ts`, `ffConnection.ts` | | `webkit/` | WebKit internal protocol | `wkBrowser.ts`, `wkPage.ts`, `wkConnection.ts` | | `bidi/` | WebDriver BiDi | `bidiChromium.ts`, `bidiFirefox.ts` | | `android/` | ADB | `android.ts` | | `electron/` | Electron/CDP | `electron.ts` | ## Dispatcher Layer Dispathers do not implement things, they translate protocol to the server code calls. ### Dispatcher — Base Class ``` packages/playwright-core/src/server/dispatchers/dispatcher.ts ``` Dispatchers bridge server objects to the protocol. Each wraps an `SdkObject` and exposes methods matching the protocol channel. ```typescript class Dispatcher ``` Key properties: - `connection: DispatcherConnection` — the server-side connection - `_object: Type` — the wrapped server object - `_guid: string` — same GUID as the server object - `_type: string` — type name matching protocol - `_parent: ParentScopeType` — parent dispatcher - `_dispatchers: Map` — child dispatchers Key methods: - `_dispatchEvent(method, params)` — sends event to client via `connection.sendEvent()` - `_runCommand(callMetadata, method, params)` — wraps method call in `ProgressController`, calls `this[method](params, progress)` - `_dispose()` — recursively disposes self and children, sends `__dispose__` to client - `adopt(child)` — reparents child dispatcher, sends `__adopt__` to client - `addObjectListener(event, handler)` — listens on wrapped server object, auto-cleaned on dispose ### Dispatcher Creation Pattern Dispatchers use a static factory to ensure one-dispatcher-per-object: ```typescript static from(parentScope, object): XxxDispatcher { return parentScope.connection.existingDispatcher(object) || new XxxDispatcher(parentScope, object); } ``` The constructor sends `__create__` to the client with the initializer data. ### DispatcherConnection Server-side counterpart to client's `Connection`: - `_dispatcherByGuid` — all dispatchers by GUID - `_dispatcherByObject` — maps server objects to their dispatchers (ensures 1:1) - `dispatch(message)` — validates params, creates `CallMetadata`, calls instrumentation hooks, runs dispatcher method, validates result, sends response - `sendCreate/sendAdopt/sendDispose/sendEvent` — lifecycle messages to client - GC: buckets with limits (JSHandle/ElementHandle: 100k, others: 10k); oldest 10% disposed when exceeded ### Dispatcher Hierarchy ``` RootDispatcher └── PlaywrightDispatcher ├── BrowserTypeDispatcher (per engine) │ └── BrowserDispatcher │ └── BrowserContextDispatcher │ ├── PageDispatcher │ │ ├── FrameDispatcher (main + child frames) │ │ ├── WorkerDispatcher │ │ └── ... │ ├── TracingDispatcher │ └── APIRequestContextDispatcher ├── AndroidDispatcher ├── ElectronDispatcher └── LocalUtilsDispatcher ``` ### Key Dispatcher Files | File | Dispatches for | |------|---------------| | `playwrightDispatcher.ts` | Playwright, BrowserType registration | | `browserTypeDispatcher.ts` | BrowserType (launch, connect) | | `browserDispatcher.ts` | Browser | | `browserContextDispatcher.ts` | BrowserContext | | `pageDispatcher.ts` | Page, Worker, BindingCall | | `frameDispatcher.ts` | Frame | | `networkDispatchers.ts` | Request, Response, Route, WebSocket, APIRequestContext | | `elementHandlerDispatcher.ts` | ElementHandle | | `jsHandleDispatcher.ts` | JSHandle | | `dialogDispatcher.ts` | Dialog | | `tracingDispatcher.ts` | Tracing | | `artifactDispatcher.ts` | Artifact | ## End-to-End Flow Example `await page.goto('https://example.com')`: ``` CLIENT: Page.goto() → _wrapApiCall() captures stack trace, creates ApiZone → _channel.goto({ url, timeout }) → Proxy validates PageGotoParams → connection.sendMessageToServer(page, 'goto', params) → sends { id: 1, guid: 'page@abc', method: 'goto', params: {...} } → waits on callback promise SERVER: DispatcherConnection.dispatch(message) → validates PageGotoParams (wire → objects) → creates CallMetadata → instrumentation.onBeforeCall() → PageDispatcher._runCommand('goto', params) → ProgressController.run(progress => this.goto(params, progress)) → PageDispatcher.goto(): this._object.mainFrame().goto(progress, url, params) → Frame.goto() → PageDelegate.navigateFrame() → CDP/protocol call → validates PageGotoResult (objects → wire) → instrumentation.onAfterCall() → sends { id: 1, result: { response: { guid: 'response@xyz' } } } CLIENT: connection.dispatch(response) → validates PageGotoResult (wire → objects) → resolves callback promise → _wrapApiCall completes, returns Response object ``` ## Object Lifecycle 1. **Creation**: Server creates SdkObject → dispatcher constructor sends `__create__` → client `Connection.dispatch()` instantiates `ChannelOwner` subclass 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`.