
Playwright Dev
- 2.8k installs
- 93.6k repo stars
- Updated July 28, 2026
- microsoft/playwright
playwright-dev is a contributor skill for extending Playwright APIs, MCP tools, CLI commands, vendor bundles, and WebView backends in the monorepo.
About
Playwright Development Guide orients contributors inside the Playwright monorepo when extending the framework itself. It points to library architecture docs covering client-server dispatchers, protocol layers, and DEPS rules, plus guides for adding APIs, MCP tools, CLI commands, and vendor dependency bundles. Additional references cover WebKit Safari version strings, the iOS WebView backend with provisional-target pause and resume behavior, bisecting published npm versions, and the dashboard behind playwright cli show. Agents use it when modifying Playwright internals rather than writing end-user tests against applications. The skill assumes familiarity with monorepo build, test, and lint commands documented in the root CLAUDE.md and check_deps validation for dependency boundaries. Use it when implementing new Playwright APIs, registering MCP tools, adjusting utilsBundle or babelBundle vendoring, updating WebView targets, or debugging regressions across published Playwright versions.
- Monorepo map for library architecture, protocol layer, and DEPS.list constraints.
- Guides for adding APIs with client-server implementations and tests.
- MCP tools and CLI command extension patterns with config options.
- Vendor dependency bundling rules for utilsBundle, coreBundle, and babelBundle.
- WebView iOS backend and bisect-published-versions regression workflows.
Playwright Dev by the numbers
- 2,766 all-time installs (skills.sh)
- +112 installs in the week ending Jul 28, 2026 (Skillselion tracking)
- Ranked #315 of 2,184 Testing & QA skills by installs in the Skillselion catalog
- Security screen: MEDIUM risk (skills.sh audit)
- Data as of Jul 28, 2026 (Skillselion catalog sync)
playwright-dev capabilities & compatibility
- Capabilities
- playwright library client server api extension · mcp tool and cli command registration · vendor bundle and deps.list dependency managemen · webkit safari version and webview backend update · published version bisect regression analysis · dashboard work for playwright cli show
- Use cases
- testing · api development · orchestration
What playwright-dev says it does
Explains how to develop Playwright - add APIs, MCP tools, CLI commands, and vendor dependencies.
npx skills add https://github.com/microsoft/playwright --skill playwright-devAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 2.8k |
|---|---|
| repo stars | ★ 93.6k |
| Security audit | 2 / 3 scanners passed |
| Last updated | July 28, 2026 |
| Repository | microsoft/playwright ↗ |
How do I modify Playwright internals, MCP tools, or CLI commands without breaking monorepo DEPS and protocol rules?
Contribute to Playwright by adding APIs, MCP tools, CLI commands, vendor bundles, and WebView backend changes.
Who is it for?
Playwright maintainers and contributors extending framework APIs, tools, or bundling.
Skip if: Skip when you only write consumer end-to-end tests; use Playwright test docs instead.
When should I use this skill?
User develops Playwright itself, adds APIs, MCP tools, CLI commands, or vendor dependencies.
What you get
Correct contributor changes with architecture-aligned API, tool, CLI, or vendor updates and linked test guidance.
- new Playwright APIs
- MCP tool definitions
- CLI commands
Files
Playwright Development Guide
See CLAUDE.md for monorepo structure, build/test/lint commands, and coding conventions.
Detailed Guides
- Library Architecture — client/server/dispatcher structure, protocol layer, DEPS rules
- Adding and Modifying APIs — define API docs, implement client/server, add tests
- MCP Tools and CLI Commands — add MCP tools, CLI commands, config options
- Vendor Dependencies & Bundling — utilsBundle, coreBundle, babelBundle; adding vendored npm packages; DEPS.list;
check_deps - Updating WebKit Safari Version — update the Safari version string in the WebKit user-agent
- WebView (iOS Safari) Backend —
webkit/webview/against stock Mobile Safari; provisional-target pause/resume; what's upstream vs Playwright patches; local + CI test setup - Bisecting Across Published Versions — reproduce regressions side-by-side from npm and diff
node_modules/playwright/lib/between versions - Dashboard - the UI powering the "playwright cli show" command, and how to work on it
Adding and Modifying APIs
- Before performing the implementation, go over the steps to understand and plan the work ahead. It is important to follow the steps in order, as some of them are prerequisites for others.
Step 1: Define API in Documentation
Define (or update) API in docs/src/api/class-xxx.md. For the new methods, params and options use the version from package.json (without -next).
Documentation Format
Method definition:
## async method: Page.methodName
* since: v1.XX
- returns: <[null]|[Response]>
Description of the method.
### param: Page.methodName.paramName
* since: v1.XX
- `paramName` <[string]>
Description of the parameter.
### option: Page.methodName.optionName
* since: v1.XX
- `optionName` <[string]>
Description of the option.Key syntax rules:
* since: v1.XX— always take the version from package.json (without -next)* langs: js, python— language filter (optional)* langs: alias-java: navigate— language-specific method name* deprecated: v1.XX— deprecation marker<[TypeName]>— type annotation:<[string]>,<[int]>,<[float]>,<[boolean]><[null]|[Response]>— union type<[Array]<[Locator]>>— array type<[Object]>with indented- \field\<[type]>— object type### param:— required parameter### option:— optional parameter= %%-placeholder-name-%%— reuse shared param definition fromdocs/src/api/params.md
Property definition:
## property: Page.propName
* since: v1.XX
- type: <[string]>
Description.Event definition:
## event: Page.eventName
* since: v1.XX
- argument: <[Dialog]>
Description.Keep methods, events and property definitions sorted alphabetically within the file.
Watch will kick in and auto-generate:
packages/playwright-core/types/types.d.ts— public API typespackages/playwright/types/test.d.ts— test API types
Step 2: Implement Client API
Implement the new API in packages/playwright-core/src/client/xxx.ts.
Client Implementation Pattern
Client classes extend ChannelOwner<XxxChannel> and call through this._channel:
// Direct channel call (most common)
async methodName(param: string, options: channels.FrameMethodNameOptions = {}): Promise<void> {
await this._channel.methodName({ param, ...options, timeout: this._timeout(options) });
}
// Channel call with response wrapping
async goto(url: string, options: channels.FrameGotoOptions = {}): Promise<network.Response | null> {
return network.Response.fromNullable(
(await this._channel.goto({ url, ...options, timeout: this._timeout(options) })).response
);
}Key patterns:
- Parameters are assembled into a single object for the channel call
- Timeout is processed through
this._timeout(options)orthis._navigationTimeout(options) - Return values from channel are unwrapped/converted:
Response.fromNullable(),ElementHandle.from(), etc. - Locator methods delegate to Frame:
return await this._frame.click(this._selector, { strict: true, ...options }) - Page methods often delegate to
this._mainFrame
Step 3: Define Protocol Channel
Define (or update) channel for the API in packages/protocol/src/protocol.yml as needed.
Protocol YAML Format
Methods are defined under commands: in the interface section:
Page:
type: interface
extends: EventTarget
commands:
methodName:
title: Short description for tracing
parameters:
url: string # required string
timeout: float # required float
referer: string? # optional string (? suffix)
waitUntil: LifecycleEvent? # optional reference to another type
button: # optional enum
type: enum?
literals:
- left
- right
- middle
modifiers: # optional array of enums
type: array?
items:
type: enum
literals:
- Alt
- Control
- Meta
- Shift
position: Point? # optional reference type
viewportSize: # required inline object
type: object
properties:
width: int
height: int
returns:
response: Response? # optional return value
flags:
slowMo: true
snapshot: true
pausesBeforeAction: trueType primitives: string, int, float, boolean, binary, json Optional: append ? to any type: string?, int?, object? Arrays: type: array with items: (or type: array? for optional) Enums: type: enum with literals: list References: use type name directly: Response, Frame, Point Flags: slowMo, snapshot, pausesBeforeAction, pausesBeforeInput
Watch will kick in and auto-generate:
packages/protocol/src/channels.d.ts— channel TypeScript interfacespackages/playwright-core/src/protocol/validator.ts— runtime validatorspackages/playwright-core/src/utils/isomorphic/protocolMetainfo.ts— method metadata
Step 4: Implement Dispatcher
Implement dispatcher handler in packages/playwright-core/src/server/dispatchers/xxxDispatcher.ts as needed.
Dispatcher Pattern
Dispatchers receive validated params and route to server objects:
// Simple pass-through (most common)
async methodName(params: channels.PageMethodNameParams, progress: Progress): Promise<void> {
await this._page.methodName(progress, params.value);
}
// With response wrapping
async goto(params: channels.FrameGotoParams, progress: Progress): Promise<channels.FrameGotoResult> {
return { response: ResponseDispatcher.fromNullable(this._browserContextDispatcher,
await this._frame.goto(progress, params.url, params)) };
}
// With dispatcher extraction (when params contain dispatcher references)
async expectScreenshot(params: channels.PageExpectScreenshotParams, progress: Progress): Promise<channels.PageExpectScreenshotResult> {
const mask = (params.mask || []).map(({ frame, selector }) => ({
frame: (frame as FrameDispatcher)._object,
selector,
}));
return await this._page.expectScreenshot(progress, { ...params, mask });
}
// With array result wrapping
async querySelectorAll(params: channels.FrameQuerySelectorAllParams, progress: Progress): Promise<channels.FrameQuerySelectorAllResult> {
const elements = await progress.race(this._frame.querySelectorAll(params.selector));
return { elements: elements.map(e => ElementHandleDispatcher.from(this, e)) };
}Key patterns:
- Method signature:
async method(params: channels.XxxMethodParams, progress: Progress): Promise<channels.XxxMethodResult> - Extract params:
params.url,params.selector, etc. - Convert dispatcher refs to server objects:
(params.frame as FrameDispatcher)._object - Wrap server objects as dispatchers in results:
ResponseDispatcher.fromNullable(),ElementHandleDispatcher.from() - All methods receive
Progressfor timeout/cancellation
Step 5: Implement Server Logic
Handler should route the call into the corresponding method in packages/playwright-core/src/server/xxx.ts.
Server methods implement the actual browser interaction:
// In packages/playwright-core/src/server/frames.ts
async goto(progress: Progress, url: string, options: types.GotoOptions = {}): Promise<network.Response | null> {
// ... validation, URL construction ...
// Delegates to browser-specific implementation:
const result = await this._page.delegate.navigateFrame(this, url, referer);
// ... wait for lifecycle events ...
return response;
}Browser-specific implementations live in:
packages/playwright-core/src/server/chromium/crPage.ts— Chromium (uses CDP:this._client.send('Page.navigate', { ... }))packages/playwright-core/src/server/firefox/ffPage.ts— Firefoxpackages/playwright-core/src/server/webkit/wkPage.ts— WebKit
Step 6: Write Tests
Test Location
- Page-only tests:
tests/page/xxx.spec.ts— usepagefixture - Context tests:
tests/library/xxx.spec.ts— usecontextfixture
Test Patterns
Page test:
import { test as it, expect } from './pageTest';
it('should do something @smoke', async ({ page, server }) => {
await page.goto(server.EMPTY_PAGE);
// ... assertions ...
expect(page.url()).toBe(server.EMPTY_PAGE);
});
it('should handle options', async ({ page, server, browserName, isAndroid }) => {
it.skip(isAndroid, 'Not supported on Android');
it.info().annotations.push({ type: 'issue', description: 'https://github.com/user/repo/issues/123' });
// ...
});Library/context test:
import { contextTest as it, expect } from '../config/browserTest';
it('should work with context', async ({ context, server }) => {
const page = await context.newPage();
await page.goto(server.EMPTY_PAGE);
// ...
});Available Fixtures
page— isolated page instancecontext— browser context (library tests)server— HTTP test server (server.EMPTY_PAGE,server.PREFIX,server.CROSS_PROCESS_PREFIX)httpsServer— HTTPS test serverasset(name)— path to test asset filebrowserName—'chromium' | 'firefox' | 'webkit'channel— browser channel stringisAndroid,isBidi,isElectron— platform booleansisWindows,isMac,isLinux— OS booleansmode— test mode ('default','service', etc.)
Running Tests
npm run ctest tests/page/xxx.spec.ts # Chromium only
npm run test tests/page/xxx.spec.ts # All browsers
npm run ctest -- --grep "should do something" # Filter by nameArchitecture Overview
docs/src/api/class-xxx.md (API documentation — source of truth for public types)
→ auto-generates → types.d.ts, test.d.ts
packages/protocol/src/protocol.yml (RPC protocol definition)
→ auto-generates → channels.d.ts, validator.ts, protocolMetainfo.ts
Client call chain:
user code → Page.method() → Frame.method() → this._channel.method(params)
→ Proxy validates & sends → Connection.sendMessageToServer()
→ [wire] →
DispatcherConnection.dispatch() → XxxDispatcher.method(params, progress)
→ ServerObject.method(progress, ...) → BrowserDelegate (CDP/Firefox/WebKit)Bisecting a Regression Across Published Playwright Versions
When a user reports a regression between two published Playwright versions (e.g. "works in 1.58, broken in 1.59.1"), reproduce both side by side from npm — do not try to bisect against the monorepo source. Reading the compiled JS in node_modules/playwright/lib/** is faster and avoids build/branch confusion.
Setup (two side-by-side installs)
Use ~/tmp/<version-tag>/ (NOT /tmp/) — the user's shell sessions live in ~/tmp.
mkdir -p ~/tmp/<good>/tests ~/tmp/<bad>/tests
# Skip `npm init playwright@latest` — it's interactive and the scaffold
# pulls in 3 projects (chromium/firefox/webkit) which produces 6 test runs
# from a single spec and is confusing. Do this instead:
( cd ~/tmp/<good> && npm init -y && npm install @playwright/test@<good-ver> && npx playwright install chromium)
( cd ~/tmp/<bad> && npm init -y && npm install @playwright/test@<bad-ver> && npx playwright install chromium )Write a minimal playwright.config.ts with a single chromium project — the default scaffold's 3-project config will run the same spec 6 times and obscure output:
import { defineConfig, devices } from '@playwright/test';
export default defineConfig({
testDir: './tests',
projects: [{ name: 'chromium', use: { ...devices['Desktop Chrome'] } }],
});Drop the repro spec (and any helper files) into both folders identically. Run:
( cd ~/tmp/<good> && npx playwright test )
( cd ~/tmp/<bad> && npx playwright test )Confirm the difference is real before investigating.
Investigating the diff in node_modules
The compiled JS in node_modules/playwright-core/lib/ and node_modules/playwright/lib/ is the source of truth for what shipped.
In recent versions of Playwright these are bundled, so you can't compare on per-file basis. You can extract files from bundles via grep though and compare.
Once you've found a candidate function, diff it across the two versions. Patches are usually 1–3 lines.
Verifying the hypothesis
Edit the compiled JS in ~/tmp/<bad>/node_modules/playwright/lib/... directly and re-run the test. No build step is needed — Node loads the JS as-is. Revert when done (or just delete the folder).
For stack-trace bugs in particular, a console.log(new Error().stack) inserted at the capture site (e.g. inside expect.js's captureRawStack) instantly shows whether the issue is microtask-boundary related vs. a stack-filter regression vs. something else.
Reporting
When the root cause is confirmed:
1. Quote the offending lines from node_modules/.../lib/... of the bad version, with file path. 2. Show the equivalent code from the good version for contrast. 3. Explain why the change breaks the user's case (don't just point at the diff). 4. Propose and verify a minimal fix by patching the bad install in place.
Post the writeup as a comment on the original issue with gh issue comment <number> --repo microsoft/playwright --body "$(cat <<'EOF' ... EOF)".
Pitfalls
- Don't run `npm init playwright@latest` — it's interactive and
--quietdoes not skip the prompts.npm init -y+npm install @playwright/test@<ver>is faster and deterministic. - Don't use the scaffold's default config — the 3 browser projects multiply test runs by 3 and confuse the output. One chromium project is enough for 99% of repros.
- Don't `cd` between commands in a single Bash call without `&&` — the shell cwd resets between tool invocations.
- `/tmp/` is not `~/tmp/` — pick one and stay consistent. The user's interactive shells default to
~/tmp/, so prefer that. - Don't `rm -rf` an existing `~/tmp/<ver>/` without checking — it may be the user's prior work. Edit in place instead.
- Don't try to map the bug to monorepo source first. The shipped JS is what the user is running; source may have already been refactored or fixed on
main. Investigatenode_modules/first, then map the fix back to source only when proposing the upstream patch.
Developing Dashboard
packages/dashboard contains the sourcecode behind playwright cli show, a dashboard that allow supervising agents while they use playwright cli.
Important code paths:
packages/dashboardhas the UIdashboardController.tshas the backendshowsection incli-client/program.ts
You can use Playwright CLI to look at the dashboard:
# start the dashboard server in the background
npx playwright cli show --port=0
# open it with Playwright CLI
npx playwright cli open --session=dashboard localhost:PORT
npx playwright cli snapshot
# take screenshots to look at UI stuff
npx playwright cli screenshot
# take videos to showcase you work!
npx playwright cli video-start video.webm
# chapters are not everything - look at video-recording.md to learn about overlays, much more powerful! embrace creativity.
npx playwright cli video-chapter "Chapter Title" --description="Details" --duration=2000
npx playwright cli video-stop
# afterwards, use ffmpeg to turn the video into mp4 for sharing.Full CLI reference: packages/playwright-core/src/tools/cli-client/skill/SKILL.md. In this repo, invoke as npx playwright cli instead of playwright-cli.
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)
packages/playwright-core/src/
client/ — public API objects (ChannelOwner subclasses)
channels.d.ts — generated client channel interfaces
server/ — browser automation implementation (SdkObject subclasses)
channels.d.ts — generated server channel interfaces
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.
Entries can be relative paths, alias paths (@isomorphic/**, @utils/**), or node_modules/<pkg> to allow a specific npm package import. The "strict" marker disables inheritance from parent folders. Section headers like [filename.ts] scope rules to a single file.
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.tscan import browser engines (./chromium/,./firefox/,./webkit/,./bidi/,./android/,./electron/) - Only
devtoolsController.tscan 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.
Vendored npm packages (anything under node_modules/) go through src/utilsBundle.ts — a single bundled file that re-exports the vendored symbols. Adding a new dep or changing a DEPS.list entry for vendored code: see vendor.md.
Protocol Layer
protocol.yml
Defines all RPC interfaces, commands (methods), events, and types. Example:
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: stringCode Generation
Running node utils/generate_channels.js (or via watch) produces:
packages/protocol/src/channels.d.ts— TypeScript types:PageChannel,PageGotoParams,PageGotoResult,PageInitializer, event typespackages/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<T>:
packages/playwright-core/src/client/channelOwner.tsKey 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<string, ChannelOwner>— 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.tsManages the client-server transport:
_objects: Map<string, ChannelOwner>— all live remote objects by GUID_callbacks: Map<number, {resolve, reject}>— pending RPC calls by message IDsendMessageToServer(object, method, params, apiZone)— sends RPC call, returns promisedispatch(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 |
Server Layer
SdkObject — Base Class
Every server-side domain object extends SdkObject:
packages/playwright-core/src/server/instrumentation.tsKey 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:
interface PageDelegate {
navigateFrame(frame, url, referer): Promise<GotoResult>;
takeScreenshot(progress, format, ...): Promise<Buffer>;
adoptElementHandle(handle, to): Promise<ElementHandle>;
// ... more browser-specific operations
}Implementations:
packages/playwright-core/src/server/chromium/crPage.ts— uses CDPpackages/playwright-core/src/server/firefox/ffPage.tspackages/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.tsDispatchers bridge server objects to the protocol. Each wraps an SdkObject and exposes methods matching the protocol channel.
class Dispatcher<Type extends SdkObject, ChannelType, ParentScopeType extends DispatcherScope>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<string, DispatcherScope>— child dispatchers
Key methods:
_dispatchEvent(method, params)— sends event to client viaconnection.sendEvent()_runCommand(callMetadata, method, params)— wraps method call inProgressController, callsthis[method](params, progress)_dispose()— recursively disposes self and children, sends__dispose__to clientadopt(child)— reparents child dispatcher, sends__adopt__to clientaddObjectListener(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:
static from(parentScope, object): XxxDispatcher {
return parentScope.connection.existingDispatcher<XxxDispatcher>(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, createsCallMetadata, calls instrumentation hooks, runs dispatcher method, validates result, sends responsesendCreate/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
└── LocalUtilsDispatcherKey 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 objectObject 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.
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.
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:
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.tsConfiguration
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.
MCP Tools and CLI Commands
Adding MCP Tools
Step 1: Create the Tool File
Create packages/playwright-core/src/tools/backend/<your-tool>.ts.
Import zod from the MCP bundle and use defineTool or defineTabTool:
import { z } from '../../zodBundle';
import { defineTool, defineTabTool } from './tool';Choose `defineTabTool` vs `defineTool`:
defineTabTool— most tools use this. Receives aTabobject, auto-handles modal state (dialogs/file choosers).defineTool— receives the fullContext. Use when you needcontext.ensureBrowserContext()without a specific tab, or need custom tab management.
Tool definition pattern:
const myTool = defineTabTool({
capability: 'core', // ToolCapability — see step 2
// Optional: only available in skill mode (not exposed via MCP)
// skillOnly: true,
// Optional: this tool clears a modal state ('dialog' | 'fileChooser')
// clearsModalState: 'dialog',
schema: {
name: 'browser_my_tool', // MCP tool name (browser_ prefix)
title: 'My Tool', // Human-readable title
description: 'Does something', // Description shown to LLM
inputSchema: z.object({
ref: z.string().describe('Element reference from snapshot'),
value: z.string().optional().describe('Optional value'),
}),
type: 'action', // 'input' | 'assertion' | 'action' | 'readOnly'
},
handle: async (tab, params, response) => {
// Implementation using tab.page (Playwright Page object)
await tab.page.click(`[ref="${params.ref}"]`);
// Add generated Playwright code
response.addCode(`await page.click('[ref="${params.ref}"]');`);
// Include page snapshot in response (for navigation/state changes)
response.setIncludeSnapshot();
// Or add text result
response.addTextResult('Done');
},
});
export default [myTool];Schema type values:
'action'— state-changing operations (navigate, click, fill)'input'— user input (typing, keyboard)'readOnly'— queries that don't modify state (list cookies, get snapshot)'assertion'— testing/verification tools
Response API:
response.addTextResult(text)— add text to result sectionresponse.addError(error)— add error messageresponse.addCode(code)— add generated Playwright code snippetresponse.setIncludeSnapshot()— include ARIA snapshot in responseresponse.setIncludeFullSnapshot(filename?)— force full snapshotresponse.addResult(title, data, fileTemplate)— add file resultresponse.registerImageResult(data, 'png'|'jpeg')— add image
Context tool example (for browser-context-level operations):
const myContextTool = defineTool({
capability: 'storage',
schema: { /* ... */ type: 'readOnly' },
handle: async (context, params, response) => {
const browserContext = await context.ensureBrowserContext();
const cookies = await browserContext.cookies();
response.addTextResult(cookies.map(c => `${c.name}=${c.value}`).join('\n'));
},
});Step 2: Add ToolCapability (if needed)
If your tool doesn't fit an existing capability, add a new one to packages/playwright-core/src/tools/mcp/config.d.ts:
export type ToolCapability =
'config' |
'core' | // Always enabled
'core-navigation' | // Always enabled
'core-tabs' | // Always enabled
'core-input' | // Always enabled
'core-install' | // Always enabled
'network' |
'pdf' |
'storage' |
'testing' |
'vision' |
'devtools'; // Add yours hereCapability filtering rules:
- Tools with
core*capabilities are always enabled - Other capabilities must be enabled via
--capsor configcapabilitiesarray skillOnly: truetools are only available in skill mode, never via MCP
Step 3: Register the Tool
In packages/playwright-core/src/tools/backend/tools.ts:
import myTool from './myTool';
export const browserTools: Tool<any>[] = [
// ... existing tools ...
...myTool,
];Step 4: Write Tests
Create tests/mcp/<category>.spec.ts. Use the fixtures from ./fixtures:
import { test, expect } from './fixtures';
test('browser_my_tool', async ({ client, server }) => {
// Setup: navigate to a page first
await client.callTool({
name: 'browser_navigate',
arguments: { url: server.PREFIX },
});
// Call your tool
expect(await client.callTool({
name: 'browser_my_tool',
arguments: { ref: 'e1' },
})).toHaveResponse({
code: `await page.click('[ref="e1"]');`,
snapshot: expect.stringContaining('some content'),
});
});
test('browser_my_tool error case', async ({ client }) => {
expect(await client.callTool({
name: 'browser_my_tool',
arguments: { ref: 'invalid' },
})).toHaveResponse({
error: expect.stringContaining('Error:'),
isError: true,
});
});Test fixtures:
client— MCP client, call tools viaclient.callTool({ name, arguments })startClient(options?)— client factory, for custom config/args/rootsserver— HTTP test server (server.PREFIX,server.HELLO_WORLD,server.setContent(path, html, contentType))httpsServer— HTTPS test server
Custom matchers:
toHaveResponse({ code?, snapshot?, page?, error?, isError?, result?, events?, modalState? })— matches parsed response sectionstoHaveTextResponse(text)— matches raw text with normalization
Parsed response sections:
code— generated Playwright code (without ```js fences)snapshot— ARIA page snapshot (with ```yaml fences)page— page info (URL, title)error— error messageresult— text resultevents— console messages, downloadsmodalState— active dialog/file chooser infotabs— tab listingisError— boolean
Testing MCP Tools
- Run tests:
npm run ctest-mcp <category> - Do not run
test --debug
---
Adding CLI Commands
CLI commands are thin wrappers over MCP tools. They live in the daemon and map CLI args to MCP tool calls.
Step 1: Implement the MCP Tool
Implement the corresponding MCP tool first (see section above). CLI commands call MCP tools via toolName/toolParams.
Step 2: Add the Command Declaration
In packages/playwright-core/src/tools/cli-daemon/commands.ts, use declareCommand():
import { z } from '../../zodBundle';
import { declareCommand } from './command';
const myCommand = declareCommand({
name: 'my-command', // CLI command name (kebab-case)
description: 'Does something', // Shown in help
category: 'core', // Category for help grouping
// Positional arguments (ordered, parsed from CLI positional args)
args: z.object({
url: z.string().describe('The URL to navigate to'),
ref: z.string().optional().describe('Optional element reference'),
}),
// Named options (parsed from --flag or --flag=value)
options: z.object({
submit: z.boolean().optional().describe('Whether to submit'),
filename: z.string().optional().describe('Output filename'),
}),
// MCP tool name — string or function for dynamic routing
toolName: 'browser_my_tool',
// OR dynamic:
// toolName: ({ submit }) => submit ? 'browser_submit' : 'browser_type',
// Map CLI args/options to MCP tool params
toolParams: ({ url, ref, submit, filename }) => ({
url,
ref,
submit,
filename,
}),
});Then add to the commandsArray at the bottom of the file, in the correct category section:
const commandsArray: AnyCommandSchema[] = [
// core category
open,
close,
// ... existing commands ...
myCommand, // <-- add here in the right category
// ...
];Categories (defined in packages/playwright-core/src/tools/cli-daemon/command.ts):
type Category = 'core' | 'navigation' | 'keyboard' | 'mouse' | 'export' |
'storage' | 'tabs' | 'network' | 'devtools' | 'browsers' |
'config' | 'install';To add a new category: 1. Add it to Category type in packages/playwright-core/src/tools/cli-daemon/command.ts 2. Add it to the categories array in packages/playwright-core/src/tools/cli-daemon/helpGenerator.ts:
const categories: { name: Category, title: string }[] = [
// ... existing ...
{ name: 'mycat', title: 'My Category' },
];Special tool patterns:
toolName: ''— command handled specially by daemon (e.g.,close,list,install)- Use
numberArgfor numeric CLI args:x: numberArg.describe('X coordinate') - Param renaming:
toolParams: ({ w: width, h: height }) => ({ width, height }) - Dynamic toolName:
toolName: ({ clear }) => clear ? 'browser_clear' : 'browser_list'
Step 3: Update SKILL File
Update packages/playwright/src/skill/SKILL.md with the new command documentation. Add reference docs in packages/playwright/src/skill/references/ if the feature is complex.
Run npm run playwright-cli -- --help to verify the help output includes your new command.
Step 4: Write CLI Tests
Create tests/mcp/cli-<category>.spec.ts. Use fixtures from ./cli-fixtures:
import { test, expect } from './cli-fixtures';
test('my-command', async ({ cli, server }) => {
// Open a page first
await cli('open', server.PREFIX);
// Run your command
const { output, snapshot } = await cli('my-command', 'arg1', '--option=value');
expect(output).toContain('expected text');
expect(snapshot).toContain('expected snapshot content');
});CLI test fixtures:
cli(...args)— run CLI command, returns{ output, error, exitCode, snapshot, attachments }output— stdout textsnapshot— extracted ARIA snapshot (if present)attachments— file attachments{ name, data }[]error— stderr textexitCode— process exit code
Testing CLI Commands
- Run tests:
npm run ctest-mcp cli-<category> - Do not run
test --debug
---
Adding Config Options
When you need to add a new config option, update these files in order:
1. Type definition: packages/playwright-core/src/tools/mcp/config.d.ts
Add the option to the Config type with JSDoc:
export type Config = {
// ... existing ...
/**
* Description of the new option.
*/
myOption?: string;
};2. CLI options type: packages/playwright-core/src/tools/mcp/config.ts
Add to CLIOptions type:
export type CLIOptions = {
// ... existing ...
myOption?: string;
};If the option needs to be in FullConfig (with required/resolved values), update FullConfig and defaultConfig:
export type FullConfig = Config & {
// ... existing ...
myOption: string; // required in resolved config
};
export const defaultConfig: FullConfig = {
// ... existing ...
myOption: 'default-value',
};3. Config from CLI: configFromCLIOptions() in config.ts
Map CLI option to config:
const config: Config = {
// ... existing ...
myOption: cliOptions.myOption,
};4. Config from env: configFromEnv() in config.ts
Add environment variable mapping:
options.myOption = envToString(process.env.PLAYWRIGHT_MCP_MY_OPTION);
// For booleans: envToBoolean(process.env.PLAYWRIGHT_MCP_MY_OPTION)
// For numbers: numberParser(process.env.PLAYWRIGHT_MCP_MY_OPTION)
// For comma lists: commaSeparatedList(process.env.PLAYWRIGHT_MCP_MY_OPTION)
// For semicolon lists: semicolonSeparatedList(process.env.PLAYWRIGHT_MCP_MY_OPTION)5. MCP server CLI: packages/playwright-core/src/tools/mcp/program.ts
Add CLI flag:
command
.option('--my-option <value>', 'description of option')6. Merge config (if nested)
If the option is nested, update mergeConfig() in config.ts to deep-merge it.
Config resolution order: defaultConfig → config file → env vars → CLI args (last wins).
---
SKILL File
The skill file is located at packages/playwright/src/skill/SKILL.md. It contains documentation for all available CLI commands and MCP tools. Update it whenever you add new commands or tools.
Reference docs live in packages/playwright/src/skill/references/:
request-mocking.md— network mocking patternsrunning-code.md— code executionsession-management.md— session handlingstorage-state.md— state persistencetest-generation.md— test creationtracing.md— trace recordingvideo-recording.md— video capture
Run npm run playwright-cli -- --help to see the latest available commands and use them to update the skill file.
---
Architecture Reference
Directory Structure
packages/playwright-core/src/tools/
├── backend/ # All MCP tool implementations
│ ├── tool.ts # Tool/TabTool types, defineTool(), defineTabTool()
│ ├── tools.ts # Tool registry (browserTools array, filteredTools)
│ ├── browserBackend.ts # Browser backend
│ ├── context.ts # Browser context management
│ ├── tab.ts # Tab management
│ ├── response.ts # Response class, parseResponse()
│ ├── common.ts # close, resize
│ ├── navigate.ts # navigate, goBack, goForward, reload
│ ├── snapshot.ts # page snapshot
│ ├── form.ts # click, type, fill, select, check
│ ├── keyboard.ts # press, keydown, keyup
│ ├── mouse.ts # mouse move, click, wheel
│ ├── tabs.ts # tab management
│ ├── cookies.ts # cookie CRUD
│ ├── webstorage.ts # localStorage, sessionStorage
│ ├── storage.ts # storage state save/load
│ ├── network.ts # network requests listing
│ ├── route.ts # request mocking/routing
│ ├── console.ts # console messages
│ ├── evaluate.ts # JS evaluation
│ ├── screenshot.ts # screenshots
│ ├── pdf.ts # PDF generation
│ ├── files.ts # file upload
│ ├── dialogs.ts # dialog handling
│ ├── verify.ts # assertions
│ ├── wait.ts # wait operations
│ ├── tracing.ts # trace recording
│ ├── video.ts # video recording
│ ├── runCode.ts # run Playwright code
│ ├── devtools.ts # DevTools integration
│ ├── config.ts # config tool
│ └── utils.ts # shared utilities
├── mcp/ # MCP server
│ ├── config.d.ts # Config type, ToolCapability type
│ ├── config.ts # Config resolution, CLIOptions, FullConfig
│ ├── program.ts # MCP server CLI setup
│ ├── index.ts # MCP server entry
│ ├── browserFactory.ts # Browser factory
│ ├── extensionContextFactory.ts
│ ├── cdpRelay.ts # CDP relay
│ ├── watchdog.ts # Watchdog
│ └── log.ts # Logging
├── cli-client/ # CLI client
│ ├── program.ts # CLI client entry (argument parsing)
│ ├── session.ts # Session management
│ └── registry.ts # Session registry
├── cli-daemon/ # CLI daemon
│ ├── command.ts # Category type, CommandSchema, declareCommand(), parseCommand()
│ ├── commands.ts # All CLI command declarations
│ ├── helpGenerator.ts # Help text generation (generateHelp, generateHelpJSON)
│ ├── daemon.ts # Daemon server
│ └── program.ts # Daemon program entry
├── dashboard/ # Dashboard UI
│ ├── dashboardApp.ts # Dashboard app
│ └── dashboardController.ts
├── utils/
│ ├── socketConnection.ts # Socket connection utilities
│ └── mcp/ # MCP SDK utilities
│ ├── server.ts # MCP server wrapper
│ ├── tool.ts # ToolSchema type, toMcpTool()
│ └── http.ts # HTTP utilities
└── exports.ts # Public exports
packages/playwright/src/
└── skill/
├── SKILL.md # Skill documentation
└── references/ # Reference docs
tests/mcp/
├── fixtures.ts # MCP test fixtures (client, startClient, server)
├── cli-fixtures.ts # CLI test fixtures (cli helper)
├── <category>.spec.ts # MCP tool tests
└── cli-<category>.spec.ts # CLI command testsExecution Flow
MCP Server mode:
LLM → MCP protocol → Server.callTool(name, args)
→ zod validates input → Tool.handle(context|tab, params, response)
→ response.serialize() → MCP protocol → LLM
CLI mode:
User → `playwright-cli my-command arg1 --opt=val`
→ Client parses with minimist → sends to Daemon via socket
→ parseCommand() maps CLI args to MCP tool params via zod
→ backend.callTool(toolName, toolParams)
→ Response formatted → printed to stdoutPlaywright Trace System - Comprehensive Guide
1. Overview
The Playwright trace system is a comprehensive recording and visualization framework that captures:
- Actions (API calls, user interactions)
- Network traffic (HAR format)
- Snapshots (DOM snapshots at key moments)
- Screencast frames (video of page rendering)
- Console messages and events
- Errors and logs
- Resources (images, stylesheets, scripts, etc.)
---
2. File Structure
packages/trace/src/ - Trace Type Definitions
Located in /home/pfeldman/code/playwright/packages/trace/src/
Key Files:
- trace.ts - Core trace event type definitions
- har.ts - HTTP Archive format (network traffic)
- snapshot.ts - DOM snapshot data structures
- DEPS.list - Dependencies marker
File List:
trace/src/
├── trace.ts (183 lines) - Main trace event types
├── har.ts (189 lines) - HAR format types
├── snapshot.ts (62 lines) - Snapshot data structures
└── DEPS.list - Dependencies file---
3. Trace Event Types (trace.ts)
3.1 Core Event Types
VERSION: 8 (Current format version)
ContextCreatedTraceEvent
type ContextCreatedTraceEvent = {
version: number,
type: 'context-options',
origin: 'testRunner' | 'library',
browserName: string,
channel?: string,
platform: string,
playwrightVersion?: string,
wallTime: number, // Milliseconds since epoch
monotonicTime: number, // Internal monotonic clock
title?: string,
options: BrowserContextEventOptions,
sdkLanguage?: Language,
testIdAttributeName?: string,
contextId?: string,
testTimeout?: number,
};BeforeActionTraceEvent
Emitted when an action starts:
type BeforeActionTraceEvent = {
type: 'before',
callId: string, // Unique action identifier
startTime: number, // Monotonic time when action started
title?: string, // User-facing action name
class: string, // API class (e.g., 'Page', 'Frame')
method: string, // API method (e.g., 'click', 'goto')
params: Record<string, any>, // Method parameters
stepId?: string, // Test step identifier
beforeSnapshot?: string, // "before@<callId>"
stack?: StackFrame[], // Call stack
pageId?: string, // Associated page ID
parentId?: string, // Parent action (for nested actions)
group?: string, // Action group (e.g., 'wait', 'click')
};InputActionTraceEvent
For input/pointer interactions:
type InputActionTraceEvent = {
type: 'input',
callId: string,
inputSnapshot?: string, // "input@<callId>"
point?: Point, // Mouse/pointer coordinates
};AfterActionTraceEvent
Emitted when an action completes:
type AfterActionTraceEvent = {
type: 'after',
callId: string,
endTime: number, // Monotonic time when action ended
afterSnapshot?: string, // "after@<callId>"
error?: SerializedError, // Error if action failed
attachments?: AfterActionTraceEventAttachment[], // Files, screenshots
annotations?: AfterActionTraceEventAnnotation[], // Custom annotations
result?: any, // Return value
point?: Point, // Final pointer position
};ActionTraceEvent (Composite)
Combines before, after, and input events:
type ActionTraceEvent = {
type: 'action',
} & Omit<BeforeActionTraceEvent, 'type'>
& Omit<AfterActionTraceEvent, 'type'>
& Omit<InputActionTraceEvent, 'type'>;Other Event Types
ScreencastFrameTraceEvent - Video frame data
type ScreencastFrameTraceEvent = {
type: 'screencast-frame',
pageId: string,
sha1: string, // Resource SHA1
width: number, // Frame width
height: number, // Frame height
timestamp: number, // Frame timestamp
frameSwapWallTime?: number,
};EventTraceEvent - Browser events (dialog, navigation, etc.)
type EventTraceEvent = {
type: 'event',
time: number,
class: string, // Event source class
method: string, // Event method
params: any, // Event parameters
pageId?: string,
};ConsoleMessageTraceEvent - Console output
type ConsoleMessageTraceEvent = {
type: 'console',
time: number,
pageId?: string,
messageType: string, // 'log', 'error', 'warn', etc.
text: string,
args?: { preview: string, value: any }[],
location: { url: string, lineNumber: number, columnNumber: number },
};LogTraceEvent - Action logs
type LogTraceEvent = {
type: 'log',
callId: string,
time: number,
message: string,
};ResourceSnapshotTraceEvent - Network request
type ResourceSnapshotTraceEvent = {
type: 'resource-snapshot',
snapshot: ResourceSnapshot, // HAR Entry
};FrameSnapshotTraceEvent - DOM snapshot
type FrameSnapshotTraceEvent = {
type: 'frame-snapshot',
snapshot: FrameSnapshot,
};StdioTraceEvent - Process output (stdout/stderr)
type StdioTraceEvent = {
type: 'stdout' | 'stderr',
timestamp: number,
text?: string,
base64?: string, // Binary output
};ErrorTraceEvent - Unhandled errors
type ErrorTraceEvent = {
type: 'error',
message: string,
stack?: StackFrame[],
};---
4. HAR Format (har.ts)
Follows HTTP Archive 1.2 specification. Key structure for network traffic:
type HARFile = {
log: Log,
};
type Log = {
version: string,
creator: Creator,
browser?: Browser,
pages?: Page[],
entries: Entry[], // Network requests
};
type Entry = {
pageref?: string,
startedDateTime: string,
time: number, // Total time (ms)
request: Request,
response: Response,
cache: Cache,
timings: Timings,
serverIPAddress?: string,
connection?: string,
// Custom Playwright fields:
_frameref?: string,
_monotonicTime?: number,
_serverPort?: number,
_securityDetails?: SecurityDetails,
_wasAborted?: boolean,
_wasFulfilled?: boolean,
_wasContinued?: boolean,
_apiRequest?: boolean, // True for fetch/axios requests
};---
5. Snapshot Format (snapshot.ts)
FrameSnapshot
type FrameSnapshot = {
snapshotName?: string,
callId: string, // Associated action
pageId: string,
frameId: string,
frameUrl: string,
timestamp: number,
wallTime?: number,
collectionTime: number, // Time to capture
doctype?: string,
html: NodeSnapshot, // Encoded DOM tree
resourceOverrides: ResourceOverride[], // Inlined resources
viewport: { width: number, height: number },
isMainFrame: boolean,
};NodeSnapshot
Compact encoding of DOM tree:
type NodeSnapshot =
TextNodeSnapshot | // string
SubtreeReferenceSnapshot | // [ [snapshotIndex, nodeIndex] ]
NodeNameAttributesChildNodesSnapshot; // [ name, attributes?, ...children ]ResourceOverride
Embeds resource data in snapshot:
type ResourceOverride = {
url: string,
sha1?: string, // External resource SHA1
ref?: number // Snapshot index reference
};---
6. Trace Storage Format
File Structure
When a trace is recorded, it creates this structure in the traces directory:
traces-dir/
├── <traceName>.trace # Main events (JSONL format)
├── <traceName>.network # Network events (JSONL format)
├── <traceName>-chunk1.trace # Additional chunks (if multiple)
├── <traceName>.stacks # Stack trace metadata (optional)
└── resources/
├── <sha1> # Resource files (images, etc.)
└── <sha1>File Formats
- `.trace` and `.network`: JSONL (JSON Lines) - one event per line
- `.zip`: Optional archive containing all above files
- `resources/`: Binary blobs indexed by SHA1 hash
Live Trace Format
For live tracing (test runner):
traces-dir/
├── <testName>.json # Synthesized trace metadata
├── <testName>/
├── events.jsonl
├── network.jsonl
└── resources/---
7. Trace Recording (tracing.ts)
Located: /home/pfeldman/code/playwright/packages/playwright-core/src/server/trace/recorder/tracing.ts
Tracing Class Architecture
export class Tracing extends SdkObject implements
InstrumentationListener,
SnapshotterDelegate,
HarTracerDelegate {
// Recording state
private _state: RecordingState;
// Components
private _snapshotter?: Snapshotter; // Captures DOM snapshots
private _harTracer: HarTracer; // Records network requests
private _screencastListeners: ... // Video recording
// Methods
start(options: TracerOptions);
startChunk(progress, options);
stopChunk(progress, params);
stop(progress);
}What Gets Recorded
1. Before Action (`onBeforeCall`)
- Action metadata: class, method, parameters
- Stack trace
- "before" DOM snapshot
- Associated page/frame IDs
2. Input Actions (`onBeforeInputAction`)
- Pointer coordinates
- Input type
- Snapshot of input
3. Action Logs (`onCallLog`)
- API log messages
- User-facing messages
4. After Action (`onAfterCall`)
- Execution time
- Return value
- Error information (if failed)
- "after" DOM snapshot
- Attachments (screenshots, files)
- Annotations (custom data)
5. Network Traffic (`onEntryFinished`)
- HTTP request/response details
- Headers, cookies, body
- Timing information
- Security details
6. Snapshots (`onFrameSnapshot`, `onSnapshotterBlob`)
- Full DOM tree with inlined resources
- Viewport size
- Resource references
7. Console Messages (`onConsoleMessage`)
- Message type (log, error, warn)
- Text content
- Arguments
- Source location
8. Events
- Dialogs
- Page errors
- Navigation events
- Downloads
9. Screencast Frames
- Video frames (if screenshots enabled)
- Frame dimensions
- Timestamps
10. Stdio/Errors
- stdout/stderr output
- Unhandled errors
- Process events
Recording State
type RecordingState = {
options: TracerOptions,
traceName: string,
networkFile: string,
traceFile: string,
tracesDir: string,
resourcesDir: string,
chunkOrdinal: number,
networkSha1s: Set<string>,
traceSha1s: Set<string>,
recording: boolean,
callIds: Set<string>,
groupStack: string[], // For nested groups
};---
8. Trace Loading (traceLoader.ts)
Located: /home/pfeldman/code/playwright/packages/playwright-core/src/utils/isomorphic/trace/traceLoader.ts
TraceLoaderBackend Interface
interface TraceLoaderBackend {
entryNames(): Promise<string[]>; // List files in trace
hasEntry(entryName: string): Promise<boolean>;
readText(entryName: string): Promise<string | undefined>; // For JSONL
readBlob(entryName: string): Promise<Blob | undefined>; // For resources
isLive(): boolean; // Is this a live/developing trace?
}Built-in Backends
ZipTraceLoaderBackend (traceParser.ts)
- Loads
.trace.zipfiles - Uses ZipFile utility to read entries
- Converts file paths to file:// URLs
Load Process
async load(backend: TraceLoaderBackend, unzipProgress) {
1. Find .trace files (ordinals: "0", "1", etc.)
2. For each ordinal:
a. Read ordinal.trace (events)
b. Read ordinal.network (network events)
c. Parse with TraceModernizer
d. Read ordinal.stacks (if exists)
e. Sort actions by startTime
3. Terminate incomplete actions
4. Finalize snapshot storage
5. Build resource content-type map
6. Push ContextEntry to contextEntries[]
}Output: ContextEntry[]
type ContextEntry = {
origin: 'testRunner' | 'library',
startTime: number, // Min action startTime
endTime: number, // Max action endTime
browserName: string,
wallTime: number,
sdkLanguage?: Language,
testIdAttributeName?: string,
title?: string,
options: BrowserContextEventOptions,
pages: PageEntry[], // Screencast data
resources: ResourceSnapshot[], // HAR entries
actions: ActionEntry[], // Merged before/after events
events: EventTraceEvent[],
stdio: StdioTraceEvent[],
errors: ErrorTraceEvent[],
hasSource: boolean,
contextId: string,
testTimeout?: number,
};---
9. Trace Model (traceModel.ts)
Located: /home/pfeldman/code/playwright/packages/playwright-core/src/utils/isomorphic/trace/traceModel.ts
TraceModel Class
High-level data model for trace viewer:
class TraceModel {
// Metadata
startTime: number;
endTime: number;
browserName: string;
channel?: string;
platform?: string;
playwrightVersion?: string;
wallTime?: number;
title?: string;
options: BrowserContextEventOptions;
sdkLanguage: Language;
testIdAttributeName?: string;
traceUri: string; // URL to trace
testTimeout?: number;
// Data arrays
pages: PageEntry[]; // Page screencast data
actions: ActionTraceEventInContext[]; // All recorded actions
attachments: Attachment[]; // Screenshots, files
visibleAttachments: Attachment[]; // Non-private attachments
events: (EventTraceEvent | ConsoleMessageTraceEvent)[];
stdio: StdioTraceEvent[];
errors: ErrorTraceEvent[];
resources: ResourceEntry[]; // Network resources
sources: Map<string, SourceModel>; // Source code
errorDescriptors: ErrorDescription[]; // Parsed errors
// Counters
actionCounters: Map<string, number>; // Actions per group
hasSource: boolean; // Has source code available
hasStepData: boolean; // Has test runner data
// Methods
createRelativeUrl(path: string): string;
failedAction(): ActionTraceEventInContext;
filteredActions(actionsFilter: ActionGroup[]): ActionTraceEventInContext[];
}ActionTraceEventInContext
type ActionTraceEventInContext = ActionEntry & {
context: ContextEntry,
group?: ActionGroup, // Added by TraceModel
log: { time: number, message: string }[],
};---
10. Trace Modernizer (traceModernizer.ts)
Located: /home/pfeldman/code/playwright/packages/playwright-core/src/utils/isomorphic/trace/traceModernizer.ts
Version Support
- Latest: Version 8
- Supported: Versions 3-8
- Upgrades older traces to current format
TraceModernizer Class
class TraceModernizer {
constructor(contextEntry: ContextEntry, snapshotStorage: SnapshotStorage);
appendTrace(trace: string); // Parse JSONL trace lines
actions(): ActionEntry[]; // Get parsed actions
private _modernize(event: any); // Upgrade event to latest version
private _innerAppendEvent(event: TraceEvent); // Process event
}How It Works
1. Parses JSONL (one JSON object per line) 2. Detects trace version from first context-options event 3. Applies version-specific upgrades using _modernize_N_to_N+1() functions 4. Consolidates before/after events into unified actions 5. Builds dependency graph for nested actions 6. Stores snapshots in SnapshotStorage
---
11. Trace Viewer
Located: /home/pfeldman/code/playwright/packages/trace-viewer/src/
Structure
trace-viewer/src/
├── index.tsx # Entry point
├── sw-main.ts # Service worker
└── ui/
├── workbench.tsx # Main UI component
├── actionList.tsx # Action timeline
├── callTab.tsx # Action details
├── snapshotTab.tsx # DOM snapshot viewer
├── networkTab.tsx # Network waterfall
├── consoleTab.tsx # Console messages
├── timeline.tsx # Time-based view
├── filmStrip.tsx # Video frames
├── logTab.tsx # Action logs
├── attachmentsTab.tsx # Files, screenshots
├── playbackControl.tsx # Video playback
└── [other tabs...]Data Flow
1. Service Worker (`sw-main.ts`) - Intercepts trace URL fetch 2. Workbench Loader - Loads trace via TraceLoader 3. TraceModel - Parses and indexes loaded data 4. UI Components - Display actions, snapshots, network, etc. 5. Playback Control - Synchronizes timeline with snapshots
Key Data Models
- TraceModel - Loaded and parsed trace data
- ActionTraceEventInContext - Single action with context
- Attachment - File or screenshot data
- SourceModel - Source code + errors
---
12. CLI Commands
Located: /home/pfeldman/code/playwright/packages/playwright-core/src/cli/program.ts
show-trace Command
playwright show-trace [trace] [options]
Options:
-b, --browser <browserType> Browser to use (chromium, firefox, webkit)
-h, --host <host> Host to serve on
-p, --port <port> Port to serve on (0 = any free port)
--stdin Accept trace URLs over stdin
Examples:
$ show-trace
$ show-trace https://example.com/trace.zip
$ show-trace /path/to/trace.zip
$ show-trace /path/to/trace/dirImplementation (program.ts: 327-355)
program
.command('show-trace [trace]')
.option('-b, --browser <browserType>', ..., 'chromium')
.option('-h, --host <host>', 'Host to serve trace on')
.option('-p, --port <port>', 'Port to serve trace on')
.option('--stdin', 'Accept trace URLs over stdin')
.description('show trace viewer')
.action(function(trace, options) {
const openOptions: TraceViewerServerOptions = {
host: options.host,
port: +options.port,
isServer: !!options.stdin,
};
if (options.port !== undefined || options.host !== undefined)
runTraceInBrowser(trace, openOptions); // Opens in browser tab
else
runTraceViewerApp(trace, options.browser, openOptions); // Opens in app window
});Trace Viewer Server (traceViewer.ts)
startTraceViewerServer(options?: TraceViewerServerOptions): Promise<HttpServer>
// Routes:
// GET /trace/file?path=<filePath> → Serve trace file
// GET /trace/file?path=<path>.json → Synthesize trace metadata
// GET /trace/file?path=<traceDir>/... → Serve trace.dir contents
// GET /trace/<other> → Serve viewer assets
runTraceViewerApp(traceUrl, browserName, options)
// Opens trace viewer in persistent browser context
runTraceInBrowser(traceUrl, options)
// Opens trace viewer in browser tab (tab.open)---
13. Data Available Per Action
Per-Action Data Structure
ActionTraceEventInContext {
// Identifiers
callId: string; // Unique action ID
pageId?: string; // Associated page
parentId?: string; // Parent action (nested)
stepId?: string; // Test step ID
group?: ActionGroup; // Action category
// Timing
startTime: number; // Monotonic time (milliseconds)
endTime: number; // When action completed
// API Information
class: string; // Class name (Page, Frame, etc.)
method: string; // Method name (click, goto, etc.)
params: Record<string, any>; // Input parameters
result?: any; // Return value
// Code Location
stack?: StackFrame[]; // Call stack with file/line/column
title?: string; // User-facing name
// Snapshots
beforeSnapshot?: string; // "before@<callId>" reference
inputSnapshot?: string; // "input@<callId>" reference
afterSnapshot?: string; // "after@<callId>" reference
// Errors
error?: SerializedError; // Error message and stack
// Logging
log: { time: number, message: string }[]; // Action logs
// Attachments
attachments?: AfterActionTraceEventAttachment[];
// { name, contentType, path?, sha1?, base64? }
// Annotations
annotations?: AfterActionTraceEventAnnotation[];
// { type, description? }
// Interaction Details
point?: Point; // Pointer coordinates {x, y}
// Reference
context: ContextEntry; // Associated browser context
}Snapshot Data
Each snapshot can be accessed via TraceLoader.storage():
FrameSnapshot {
callId: string, // Associated action
pageId: string,
frameId: string,
frameUrl: string,
html: NodeSnapshot, // Encoded DOM tree
resourceOverrides: [ // Embedded resources
{ url, sha1?, ref? }
],
viewport: { width, height },
isMainFrame: boolean,
collectionTime: number, // ms to capture
timestamp: number, // Monotonic time
wallTime?: number,
}Network Data (HAR Entry)
Entry {
request: {
method: string, // GET, POST, etc.
url: string,
httpVersion: string,
headers: Header[],
cookies: Cookie[],
queryString: { name, value }[],
postData?: {
mimeType: string,
params: Param[],
text: string,
_sha1?: string, // Reference to resources/
},
},
response: {
status: number, // 200, 404, etc.
statusText: string,
headers: Header[],
cookies: Cookie[],
content: {
size: number,
mimeType: string,
text?: string,
_sha1?: string, // Reference to resources/
compression?: number,
},
redirectURL: string,
},
timings: { // All in milliseconds
blocked?: number,
dns?: number,
connect?: number,
send: number,
wait: number, // Time to first byte
receive: number,
ssl?: number,
},
time: number, // Total time
_monotonicTime?: number, // Monotonic timestamp
_wasFulfilled?: boolean,
_wasAborted?: boolean,
_apiRequest?: boolean, // fetch/axios
}---
14. Quick Reference: Accessing Trace Data
In Trace Viewer
// Load trace
const traceLoader = new TraceLoader();
const backend = new ZipTraceLoaderBackend('trace.zip');
await traceLoader.load(backend, (done, total) => {});
// Access context
const contextEntries = traceLoader.contextEntries;
// Get trace model
const traceModel = new TraceModel(traceUri, contextEntries);
// Iterate actions
for (const action of traceModel.actions) {
console.log(action.method); // e.g., "click"
console.log(action.params); // parameters
console.log(action.result); // return value
console.log(action.error); // error if failed
console.log(action.log); // log messages
}
// Get snapshots
const snapshotStorage = traceLoader.storage();
const snapshot = snapshotStorage.snapshotByName('before@<callId>');
// Get resource
const blob = await traceLoader.resourceForSha1(sha1);In Test Runner
// Access via trace via browser context
const trace = await context.tracing.stop({ path: 'trace.zip' });
// Use server-side Tracing class
const tracing = new Tracing(context, tracesDir);
tracing.start({ snapshots: true, screenshots: true });
// ... run test ...
await tracing.stopChunk(progress, { mode: 'archive' });
await tracing.stop(progress);---
15. Version Information
Trace Format Versions
- Version 3: Early format (~1.35)
- Version 4: Updates (~1.36)
- Version 5: Improvements (~1.37)
- Version 6: Major changes (~10/2023, ~1.40)
- Version 7: Further updates (~05/2024, ~1.45)
- Version 8: Current format (latest)
Compatibility
- Trace viewer automatically upgrades traces
- Newer viewer can read older traces
- Older viewer cannot read newer traces (TraceVersionError)
---
16. Key Design Patterns
1. Call ID Correlation
Every action uses a unique callId to correlate:
- Before event
- Input events
- Log messages
- After event
- Snapshots (before@callId, input@callId, after@callId)
- Attachments
- Network requests (indirect via timing)
2. Lazy Loading
- Snapshots stored by SHA1
- Resources fetched on demand
- JSONL format allows streaming
3. Snapshot References
- Instead of storing full DOM repeatedly
- Later snapshots reference earlier ones:
[[snapshotIndex, nodeIndex]] - Resources inlined via
resourceOverrides
4. Dual Time Bases
- wallTime: Milliseconds since epoch (for display)
- monotonicTime: Internal monotonic clock (for correlation)
5. Chunked Recording
- Tests can have multiple chunks
- Each chunk has separate
.tracefile - Network resources preserved across chunks
6. Grouping
- Actions can be grouped with
group()/groupEnd() - Used for test steps, fixtures
- Group tracking in
RecordingState.groupStack
---
17. File Reference Guide
| File | Size | Purpose |
|---|---|---|
trace/src/trace.ts | 183 lines | Trace event types |
trace/src/har.ts | 189 lines | Network HAR types |
trace/src/snapshot.ts | 62 lines | Snapshot types |
playwright-core/.../tracing.ts | 700+ lines | Recording engine |
playwright-core/.../traceParser.ts | 62 lines | ZIP backend |
playwright-core/.../traceViewer.ts | 288 lines | Viewer server |
playwright-core/.../traceLoader.ts | 158 lines | Load traces |
playwright-core/.../traceModel.ts | 300+ lines | Data model |
playwright-core/.../traceModernizer.ts | 500+ lines | Version upgrades |
trace-viewer/src/index.tsx | 48 lines | Viewer entry |
trace-viewer/src/ui/workbench.tsx | Main UI | Display |
Vendor Dependencies & Bundling
Playwright ships a small number of node_modules inlined into a handful of pre-built "bundle" files under lib/. Everything else is either a source file compiled per-file, or loaded at runtime from one of the bundles. This doc covers how the bundling works, how to add or move a vendored package, and how the dependency checker enforces the contract.
The Bundles
playwright-core
| Output | Entry | Purpose |
|---|---|---|
lib/utilsBundle.js | src/utilsBundle.ts | Vendored npm packages (debug, mime, ws, yauzl, yazl, @modelcontextprotocol/sdk, graceful-fs, …). The single home for third-party runtime code in playwright-core. |
lib/coreBundle.js | src/coreBundle.ts | Re-exports of playwright-core's own modules (client, iso, utils, cli, server, registry, …) as namespaces. Inlines almost all playwright-core source except utilsBundle. |
lib/server/electron/loader.js | src/server/electron/loader.ts | Tiny Electron preload shim. |
The dynamicImportToRequirePlugin in utils/build/build.js rewrites vendored npm imports at bundle time. For example, a playwright-core source file containing
import debug from 'debug';gets rewritten to
const debug = require('./utilsBundle').debug;before the bundler sees it — so the vendored package never gets inlined into coreBundle.js. The mapping from npm package name to utilsBundle export key lives in utils/build/utilsBundleMapping.js.
playwright
| Output | Entry | Purpose |
|---|---|---|
lib/transform/babelBundle.js | src/transform/babelBundle.ts | Wraps @babel/core, @babel/traverse, @babel/code-frame, plugins. Shared by every consumer that needs babel. |
lib/transform/esmLoader.js | src/transform/esmLoader.ts | Node ESM loader registered via node:module.register(). Output sits next to babelBundle.js so its ./babelBundle sibling require resolves correctly. |
lib/common/index.js | src/common/index.ts | Barrel of common/* + transform/* (compilationCache, test, configLoader, fixtures, globals, …). State-holding singletons (currentTestInfo, memoryCache, …) live here. |
lib/runner/index.js | src/runner/index.ts | Barrel of runner/* + reporters/* + plugins/*. |
lib/matchers/expect.js | src/matchers/expect.ts | Jest-style matchers with expect inlined. |
lib/worker/workerProcessEntry.js | src/worker/workerProcessEntry.ts | Entry point spawned per test worker. |
lib/loader/loaderProcessEntry.js | src/loader/loaderProcessEntry.ts | Entry point for the test file loader sub-process. |
lib/runner/uiModeReporter.js | src/runner/uiModeReporter.ts | Loaded by require.resolve from testServer; passed to child workers as a file path. |
The common and runner bundles externalize ../transform/babelBundle (among other things) so babel code is not duplicated across them. The lib/transform/transform.ts module uses libPath('transform', 'babelBundle') (absolute path via package.ts root) to load the babel bundle at runtime, so it works regardless of which bundle has inlined it.
Per-file emits (no bundle)
Files outside the bundled entries are compiled 1:1 by esbuild and land under lib/ mirroring their source layout. The per-file step in utils/build/build.js lists the specific directories for the playwright package (cli/, agents/, mcp/, root *.ts, and a few targeted files like runner/uiModeReporter.ts). Other packages (playwright-test, html-reporter, trace-viewer, …) are compiled by the generic per-package loop.
Bundle Sidecars
Every bundled output has two sidecar files next to it:
- `<bundle>.js.txt` — human-readable report listing inlined files
(sorted by path, with per-file KB sizes), externals, and total bytes. Written by utils/build/bundle_report.js.
- `<bundle>.js.LICENSE` — third-party license texts for every npm
package whose source got inlined. Populated from license-checker, memoized once per build invocation. Consumed by the top-level ThirdPartyNotices.txt files, which just point readers at the per-bundle sidecars.
Both sidecars are included in the published npm package (controlled by packages/*/.npmignore).
Adding a Vendored NPM Dependency
Three pieces need to line up when adding a new npm package that you want inlined into utilsBundle (i.e., loaded through require('./utilsBundle').<key>):
1. Install the package. Add it to the root package.json devDependencies. The monorepo root is where esbuild resolves modules from; the workspace root's node_modules/<pkg> is what gets inlined into utilsBundle.js.
2. Export it from `src/utilsBundle.ts`. Pick one of:
import fooLibrary from 'foo';
export const foo = fooLibrary; // default
import * as fooLibrary from 'foo';
export const foo = fooLibrary; // namespace
export { namedSymbol } from 'foo'; // namedType-only exports (export type { X } from 'foo') are valid and don't affect runtime.
3. Add a mapping entry to `utils/build/utilsBundleMapping.js`:
'foo': { default: 'foo' },
// or:
'foo': { namespace: 'foo' },
// or:
'foo': { named: { namedSymbol: 'fooNamedSymbol' } },default— matchesimport foo from 'foo'and rewrites to
require('./utilsBundle').foo.
namespace— matchesimport * as foo from 'foo'.named— matchesimport { namedSymbol } from 'foo'and rewrites
to const { fooNamedSymbol: namedSymbol } = require('./utilsBundle').
- Multiple forms can coexist in one entry (see
yauzl). - The map key is the exact npm specifier as written in source
(including subpaths like '@babel/core' or 'colors/safe').
4. Update DEPS.list. The file or its enclosing folder's DEPS.list must authorize node_modules/<pkg> — otherwise npm run flint's check_deps step complains about the disallowed external dependency. If the DEPS.list authorizes it, the package.json-dependencies check also gets skipped for that file.
5. Run `npm run flint`. It runs check_deps, tsc, eslint, and doc in parallel. A missing mapping typically surfaces as node_modules/ references leaking into coreBundle.js — the build fails hard via assertCoreBundleHasNoNodeModules().
In-tree Third-Party Helpers
Some vendored code isn't a published npm package but lives in-tree at packages/playwright-core/src/server/utils/third_party/ (e.g. extractZip.ts, lockfile.ts). These are TypeScript files, not node_modules. They're exposed to callers via two different routes:
- Through `coreBundle.utils`. Re-exported from
src/server/utils/index.ts via export * from './third_party/extractZip' etc. Callers import via the @utils/* path alias:
import { extractZip } from '@utils/third_party/extractZip';The alias is rewritten at bundle time to require('playwright-core/lib/coreBundle').utils.extractZip.
- Transitive npm deps via utilsBundle. When a third_party TS file
imports an npm package (e.g., lockfile.ts imports graceful-fs, retry, signal-exit), those are still rewritten through utilsBundle — so the mapping in utilsBundleMapping.js must list them too.
DEPS.list
Every directory under packages/*/src/ has a DEPS.list constraining its imports. Three kinds of entries:
| Syntax | Meaning |
|---|---|
./somefile.ts, @isomorphic/** | Relative or alias source import allowed |
node_modules/<pkg> | npm package import allowed (exact specifier match) |
"strict" | No other DEPS inherited; only what's listed is allowed |
Section headers [filename.ts] scope rules to a single file. The top-level [*] (or no header) applies to everything in the folder plus subfolders that don't have their own DEPS.list.
A DEPS.list entry of node_modules/<pkg> now shortcuts both layers of the check: the "disallowed external dependency" error AND the "dependencies not declared in package.json" report. The per-file allowlist is the contract — no need to also list the dep in packages/<pkg>/package.json if only one file uses it and it's authorized there.
check_deps.js
utils/check_deps.js walks the TypeScript program, visits every import in src/**, and for each npm specifier:
1. Skips if the source file's DEPS.list authorizes node_modules/<specifier>. 2. Otherwise records the top-level package name along with the file path that imported it. 3. Subtracts peerDependencies, VENDORED_PACKAGES (from utilsBundleMapping.js), and any package that resolves without node_modules/ (a core module or a local file). 4. Subtracts packages listed in packages/<pkg>/package.json dependencies. 5. Anything left is reported with the specific file(s) that import it.
The missing-dep error now includes file paths:
Dependencies are not declared in package.json:
expect
src/matchers/expect.ts
@babel/core
src/transform/babelBundle.tsBundle-Level Externalization (onResolve plugins)
Two onResolve plugins in utils/build/build.js normalize relative imports to the sibling bundle at consumer output level:
- `externalizeUtilsBundlePlugin` — matches any relative specifier
ending in /utilsBundle or /utilsBundle.js (at any depth: ./utilsBundle, ../utilsBundle, ../../utilsBundle) and marks it external with the single spelling ./utilsBundle. This only applies to the coreBundle build because coreBundle inlines source files from all over playwright-core/src/ (different depths) and needs a single consistent external specifier that resolves correctly at runtime from lib/coreBundle.js.
- The babelBundle case is handled differently — instead of a plugin,
consumers' source/output depths are aligned:
esmLoaderbundle output is placed atlib/transform/esmLoader.js
(same folder as babelBundle.js), so ./babelBundle from transform.ts resolves correctly.
commonandrunnerbundles declare'../transform/babelBundle'
as a static external; their outputs are at lib/common/index.js and lib/runner/index.js, both at depth 1, so the source-relative specifier resolves naturally.
transform.ts's ownrequire('./babelBundle')was replaced with
require(libPath('transform', 'babelBundle')) — an absolute path computed at runtime via package.ts, which works from any bundle.
Quick Reference
- To add a new vendored npm dep: root
package.json→utilsBundle.ts
export → utilsBundleMapping.js entry → DEPS.list → npm run flint.
- To add a new in-tree third-party helper: drop the
.tsfile under
server/utils/third_party/, re-export from server/utils/index.ts, and use @utils/third_party/<name> at call sites.
- To add a new bundle entry: add an
EsbuildStepin
utils/build/build.js, pick output location so relative externals line up with runtime layout, and list externals for every sibling bundle the entry should not inline.
- To expose a bundle file as a package subpath: add it to the
exports field in packages/<pkg>/package.json.
- To check what's inside a bundle: read the
.js.txtsidecar next to
the output.
Updating WebKit Safari Version
The Safari version string used in the WebKit user-agent is declared in one place:
[packages/playwright-core/src/server/webkit/wkBrowser.ts](../../../packages/playwright-core/src/server/webkit/wkBrowser.ts) — BROWSER_VERSION constant (line ~35).
const BROWSER_VERSION = '26.4';
const DEFAULT_USER_AGENT = `Mozilla/5.0 ... Version/${BROWSER_VERSION} Safari/605.1.15`;Steps to update
1. Find the latest stable Safari version — search site:developer.apple.com "Safari X.Y Release Notes" or check the Safari Release Notes index. The highest numbered entry that is not a Technology Preview is the current stable release.
2. Update `BROWSER_VERSION` in wkBrowser.ts.
3. Run lint to update any generated files that embed the version:
npm run flintWebView (iOS Safari) Backend Reference
Notes for developing the stock-Mobile-Safari backend in packages/playwright-core/src/server/webkit/webview/. The regular WebKit backend one level up (webkit/) runs against a Playwright-patched WebKit build; the webview/ folder targets unmodified Safari on real iOS/iPadOS or the iOS Simulator over the standard Web Inspector Protocol.
What is and isn't available
Stock Mobile Safari only exposes the upstream Web Inspector Protocol. Anything Playwright added to its forked WebKit is not available here.
Available — methods/events in Source/JavaScriptCore/inspector/protocol/*.json on browser_upstream/main:
git -C ~/webkit show browser_upstream/main:Source/JavaScriptCore/inspector/protocol/<Domain>.jsonNot available — anything added by the Playwright WebKit patches:
ls ~/playwright-browsers/browser_patches/webkit/patches/ # bootstrap.diff lives hereQuick provenance check for a symbol — in ~/webkit:
git log --all -S "<symbol>" -- <path>If every hit is a chore(webkit): bootstrap build commit, it's a Playwright patch. Some traps where the upstream name overlaps with a Playwright-only helper of similar intent:
Target.setPauseOnStart/Target.resume(upstream, gates loading of
provisional process-swap targets) vs PageInspectorController::pauseOnStart / resumeIfPausedInNewWindow (Playwright patch, for window.open popups). They sound the same; they aren't.
Playwright.*domain (cookies, navigate, all global controls) — entirely
Playwright. Use stock equivalents on the page session (e.g. Page.getCookies).
Network.continueWithAuth, intercepted-response body access via Network —
Playwright extensions.
Architecture
This backend deliberately mirrors the regular WebKit backend one level up (wkConnection.ts / wkPage.ts / wkProvisionalPage.ts). When in doubt, read the WK equivalent — the WV class should look almost the same. The outerSession is the WV analogue of WK's page-proxy session.
WebSocketTransport (ws://localhost:9222/devtools/page/<n>, via ios_webkit_debug_proxy)
→ WVConnection — dumb transport; owns only outerSession
→ WVConnection.outerSession (sessionId "") — Target.sendMessageToTarget bridge
→ WVPage — owns per-target WVSessions, routes
Target.dispatchMessageFromTarget, manages swaps
→ _session (current) + WVProvisionalPage (during a swap)
→ WVExecutionContext, WVWorkers, RawKeyboard/Mouse/Touchscreen
(all hold a session reference, all have setSession() for swap)WVConnection is intentionally minimal: it pumps the transport into outerSession and back. WVPage creates the per-target WVSessions (_createSession), routes Target.dispatchMessageFromTarget by targetId to either _session or _provisionalPage._session, and handles Target.targetCreated/targetDestroyed/didCommitProvisionalTarget — exactly like WKPage. WVPage is constructed with the outer session (not a target session); _session starts undefined and is bound on the first Target.targetCreated via _setSession. WVBrowser._attachTab awaits page.waitForInitialized() (resolves after the first target is reported as new) instead of waiting on the connection.
Process swap / provisional targets
Cross-origin navigation in an existing tab can make Mobile Safari spawn a new process. The protocol sequence is:
Target.targetCreated targetInfo:{ isProvisional:true, isPaused:true }
... events on the provisional target during the navigation ...
Target.didCommitProvisionalTarget oldTargetId, newTargetId
Target.targetDestroyed oldTargetIdHandling mirrors WK — WVProvisionalPage ≈ WKProvisionalPage, swapped in by WVPage._onDidCommitProvisionalTarget. The one essential trick: Target.setPauseOnStart (sent in WVBrowser._attachTab) makes provisional targets arrive isPaused, so WVPage can set up interception / bootstrap before resuming them with Target.resume; otherwise the new process races ahead and page.route(...) never fires.
Not to be confused with the popup-pause path (window.open), which is a Playwright patch (PageInspectorController::pauseOnStart) with no stock-Safari equivalent.
Test infrastructure
tests/webview/
playwright.config.ts — single project "webkit-webview-page", runs tests/page/
webviewTest.ts — fixture: discovers tab via ios_webkit_debug_proxy /json,
resets Mobile Safari between tests
expectations/
webkit-webview-page.txt — `<test path> › <name> [fail|flaky|timeout|skip]`
expectationUtil.ts — loader + skip-decision logic[fail] entries cause it.skip() to fire before the test body runs — so a formerly-failing test that now passes won't be flagged automatically, you have to remove the line and re-run. The marker also has prefix-match semantics for it.step children.
Running locally
./utils/run_webview_tests.sh # ensures ios_webkit_debug_proxy is up on 9222
npm run wvtest -- -g "<test title>" # single test
npm run wvtest -- tests/page/foo.spec.ts
DEBUG=pw:protocol npm run wvtest -- -g "<title>" > /tmp/log 2>&1The simulator needs to be booted with one Mobile Safari tab. The script's only job is keeping a single ios_webkit_debug_proxy -F -d -s unix:<socket> -c null:9221,:9222-9322 alive; everything else is done by the fixture.
Common local pitfalls
- Multiple `ios_webkit_debug_proxy` processes — every protocol event is
delivered through each instance, so debug logs show events duplicated and some tests flake. pgrep -lf ios_webkit_debug_proxy and clean up.
- Chrome bound to `127.0.0.1:9222` — Chrome's
--remote-debugging-port=9222
shadows iwdp's *:9222 listener for traffic addressed to localhost. Kill the Chrome instance (lsof -nP -iTCP -sTCP:LISTEN | grep 9222).
- Stale launchd socket —
lsof -aUc launchd_simshould show
com.apple.webinspectord_sim.socket for the currently booted simulator. If iwdp was started against an older launchd_sim socket path it will silently serve nothing.
CI
.github/workflows/tests_webview_simulator.yml runs the same suite on macos-15 (booted iOS Simulator + brew install ios-webkit-debug-proxy). Triggers: workflow_dispatch, and pull_request only when paths under tests/webview/**, the webview/ source folder, or the workflow file itself change.
Related skills
How it compares
Use playwright-dev for contributing to the Playwright framework itself; use Playwright testing skills for writing end-user browser automation tests.
FAQ
Is this for writing application tests?
No. It guides contributing to the Playwright framework repository.
Where are DEPS rules documented?
See the library architecture and vendor guides referenced from this skill.
How do I debug version regressions?
Use the bisect-published-versions guide to compare npm package lib folders.
Is Playwright Dev safe to install?
skills.sh reports 2 of 3 security scanners passed. Review the Security Audits panel on this page before installing in production.